From e79755dab3526d719db8afe2c1b15499b2bf88de Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 15 May 2026 16:27:34 +0200 Subject: [PATCH 1/2] fix(nip05): stop reporting verification errors as Verified --- .../model/nip05DnsIdentifiers/Nip05State.kt | 2 +- .../quartz/nip05DnsIdentifiers/Nip05Id.kt | 19 ++++++--- .../quartz/nip05DnsIdentifiers/Nip05Test.kt | 39 +++++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt index 245e7f7a5..8d3fb2019 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt @@ -45,7 +45,7 @@ sealed interface Nip05State { fun markAsVerifying() = verificationState.tryEmit(Nip05VerifState.Verifying(TimeUtils.fiveMinutesAhead())) - fun markAsError() = verificationState.tryEmit(Nip05VerifState.Verified(TimeUtils.fiveMinutesAhead())) + fun markAsError() = verificationState.tryEmit(Nip05VerifState.Error(TimeUtils.fiveMinutesAhead())) fun reset() = verificationState.tryEmit(Nip05VerifState.NotStarted) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt index 537210e8f..9706e1a90 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt @@ -47,14 +47,21 @@ data class Nip05Id( fun toDomainUrl(): String = domainUrl(domain) companion object { + // NIP-05 localpart: a-z, 0-9, -, _, . + private val LOCAL_PART_REGEX = Regex("^[a-z0-9._-]+$") + + // Hostname: dot-separated labels of [a-z0-9-], no leading/trailing hyphen, + // require at least one dot so single-label garbage (e.g. "s!ayer") is rejected. + private val DOMAIN_REGEX = + Regex("^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$") + fun parse(nip05address: String): Nip05Id? { val parts = nip05address.trim().lowercase().split("@") - - return when (parts.size) { - 2 -> Nip05Id(parts[0], parts[1]) - 1 -> Nip05Id(parts[0], "_") - else -> null - } + if (parts.size != 2) return null + val (name, domain) = parts[0] to parts[1] + if (!LOCAL_PART_REGEX.matches(name)) return null + if (!DOMAIN_REGEX.matches(domain)) return null + return Nip05Id(name, domain) } fun assemble( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt index b72358f75..721e90721 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt @@ -94,6 +94,45 @@ class Nip05Test { assertNull(parsedNip05) } + @Test + fun `parse rejects empty localpart`() { + assertNull(Nip05Id.parse("@example.com")) + } + + @Test + fun `parse rejects empty domain`() { + assertNull(Nip05Id.parse("alice@")) + } + + @Test + fun `parse rejects domain without a dot`() { + assertNull(Nip05Id.parse("alice@localhost")) + } + + @Test + fun `parse rejects domain with illegal character`() { + assertNull(Nip05Id.parse("_@s!ayer")) + assertNull(Nip05Id.parse("@s!ayer")) + } + + @Test + fun `parse rejects localpart with illegal character`() { + assertNull(Nip05Id.parse("al!ce@example.com")) + assertNull(Nip05Id.parse("alice space@example.com")) + } + + @Test + fun `parse rejects bare string without at-sign`() { + assertNull(Nip05Id.parse("alice")) + assertNull(Nip05Id.parse("example.com")) + } + + @Test + fun `parse rejects domain label with leading or trailing hyphen`() { + assertNull(Nip05Id.parse("alice@-example.com")) + assertNull(Nip05Id.parse("alice@example-.com")) + } + @Test fun `execute assemble url with valid value returns nip05 url`() { // given From 8d225c355e2627481a67167555a7774c626598d6 Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 15 May 2026 16:57:44 +0200 Subject: [PATCH 2/2] Code review: - reject ill-formed localpart dots and IP literals in parse - avoid Pair allocation in Nip05Id.parse --- .../quartz/nip05DnsIdentifiers/Nip05Id.kt | 11 +++++++--- .../quartz/nip05DnsIdentifiers/Nip05Test.kt | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt index 9706e1a90..09027f173 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt @@ -47,8 +47,9 @@ data class Nip05Id( fun toDomainUrl(): String = domainUrl(domain) companion object { - // NIP-05 localpart: a-z, 0-9, -, _, . - private val LOCAL_PART_REGEX = Regex("^[a-z0-9._-]+$") + // NIP-05 localpart: dot-separated atoms of [a-z0-9_-]. Forbids leading, + // trailing, and consecutive dots (NIP-05 + RFC 5321 local-part rules). + private val LOCAL_PART_REGEX = Regex("^[a-z0-9_-]+(\\.[a-z0-9_-]+)*$") // Hostname: dot-separated labels of [a-z0-9-], no leading/trailing hyphen, // require at least one dot so single-label garbage (e.g. "s!ayer") is rejected. @@ -58,9 +59,13 @@ data class Nip05Id( fun parse(nip05address: String): Nip05Id? { val parts = nip05address.trim().lowercase().split("@") if (parts.size != 2) return null - val (name, domain) = parts[0] to parts[1] + val name = parts[0] + val domain = parts[1] if (!LOCAL_PART_REGEX.matches(name)) return null if (!DOMAIN_REGEX.matches(domain)) return null + // Reject IP literals — NIP-05 expects a hostname, and a digits-only + // TLD is the cheapest way to tell them apart from real domains. + if (domain.substringAfterLast('.').all { it.isDigit() }) return null return Nip05Id(name, domain) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt index 721e90721..2f9e17c34 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt @@ -133,6 +133,27 @@ class Nip05Test { assertNull(Nip05Id.parse("alice@example-.com")) } + @Test + fun `parse rejects localpart with leading trailing or consecutive dots`() { + assertNull(Nip05Id.parse(".alice@example.com")) + assertNull(Nip05Id.parse("alice.@example.com")) + assertNull(Nip05Id.parse("alice..bob@example.com")) + } + + @Test + fun `parse rejects IPv4 literal as domain`() { + assertNull(Nip05Id.parse("alice@192.168.1.1")) + assertNull(Nip05Id.parse("alice@8.8.8.8")) + } + + @Test + fun `parse accepts wildcard underscore localpart`() { + val nip05 = Nip05Id.parse("_@example.com") + assertNotNull(nip05) + assertEquals("_", nip05.name) + assertEquals("example.com", nip05.domain) + } + @Test fun `execute assemble url with valid value returns nip05 url`() { // given