fix: reject non-32-byte pubkeys when decoding npub/nprofile

A Nostr pubkey is x-only, exactly 32 bytes, but NPub.parse/NProfile.parse never
checked the length — they hex-encoded whatever bytes the bech32/TLV carried. A
malformed npub/nprofile that some clients encode with the full 33-byte COMPRESSED
secp256k1 key (0x02/0x03 prefix) therefore round-tripped its 66-char hex straight
into a `p`/`q` tag via the quote/mention path, and a strict relay (relay29 /
pyramid.fiatjaf.com) rejected the whole group message:

  blocked: schema validation failed: tag[..]: invalid pubkey value
  '02977dcf…c3402' ... pubkey should be 64-char hex

We never generate compressed keys ourselves (Nip01Crypto.pubKeyCreate strips the
prefix byte); this is purely inbound malformed input. Enforce the 32-byte length
at the decode boundary so the bad entity never becomes a mention/quote tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-09 17:24:03 +00:00
parent cc048c7ad1
commit a545151a34
3 changed files with 31 additions and 2 deletions
@@ -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) })
}
@@ -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())
}
@@ -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)
}
}