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