diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt index 50aef064d6..755113f590 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt @@ -47,7 +47,9 @@ data class NProfile( val hex = tlv.firstAsHex(TlvTypes.SPECIAL) ?: return null val relay = tlv.asStringList(TlvTypes.RELAY) ?: emptyList() - if (hex.isBlank()) return null + // x-only 32-byte pubkey only (64 hex chars). Reject a 33-byte compressed + // key or any other malformed length — see NPub.parse for why it matters. + if (hex.length != 64) return null return NProfile(hex, relay.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt index 87c8b89c9e..939ee82bcf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt @@ -32,7 +32,11 @@ data class NPub( ) : IPubKeyEntity { companion object { fun parse(bytes: ByteArray): NPub? { - if (bytes.isEmpty()) return null + // A Nostr pubkey is x-only, exactly 32 bytes. Reject anything else — + // notably a 33-byte COMPRESSED secp256k1 key (0x02/0x03 prefix) that some + // clients wrongly encode into an npub. Passing its 66-char hex through into + // a `p`/`q` tag gets the whole event rejected by strict relays (relay29). + if (bytes.size != 32) return null return NPub(bytes.toHexKey()) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt index 5c250e2d4d..6462aa8cca 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip19Bech32 +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @@ -460,4 +461,26 @@ class NIP19ParserTest { withBom.bechToBytes().toNsec(), ) } + + // A 33-byte COMPRESSED secp256k1 key (0x02 prefix). Nostr pubkeys are x-only 32 bytes; some + // clients wrongly encode the compressed form. Parsing must reject it so its 66-char hex never + // reaches a `p`/`q` tag (strict relays like relay29 reject the whole event otherwise). + private val compressedPubKey = "02977dcfdd3ce61572b652e0eade0fd519108be00a74b00064eb35b746cf6c3402" + + @Test + fun rejects_compressed_pubkey_encoded_as_npub() { + assertEquals(null, NPub.parse(compressedPubKey.hexToByteArray())) + assertEquals(null, Nip19Parser.uriToRoute("nostr:" + NPub.create(compressedPubKey))?.entity) + } + + @Test + fun rejects_compressed_pubkey_encoded_as_nprofile() { + assertEquals(null, Nip19Parser.uriToRoute("nostr:" + NProfile.create(compressedPubKey, emptyList()))?.entity) + } + + @Test + fun accepts_valid_xonly_pubkey() { + val xOnly = "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365" + assertEquals(xOnly, NPub.parse(xOnly.hexToByteArray())?.hex) + } }