From a545151a343d50d2ce7bc9b49155b77ffbea3212 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 17:24:03 +0000 Subject: [PATCH] fix: reject non-32-byte pubkeys when decoding npub/nprofile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../quartz/nip19Bech32/entities/NProfile.kt | 4 +++- .../quartz/nip19Bech32/entities/NPub.kt | 6 ++++- .../quartz/nip19Bech32/NIP19ParserTest.kt | 23 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) 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) + } }