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..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,14 +47,26 @@ data class Nip05Id( fun toDomainUrl(): String = domainUrl(domain) companion object { + // 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. + 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 = 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) } 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..2f9e17c34 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,66 @@ 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 `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