diff --git a/nostr-java-api/src/main/java/nostr/api/NIP05.java b/nostr-java-api/src/main/java/nostr/api/NIP05.java index cd110b664..530d62232 100644 --- a/nostr-java-api/src/main/java/nostr/api/NIP05.java +++ b/nostr-java-api/src/main/java/nostr/api/NIP05.java @@ -44,7 +44,11 @@ public NIP05 createInternetIdentifierMetadataEvent(@NonNull UserProfile profile) private String getContent(UserProfile profile) { try { - String jsonString = MAPPER_BLACKBIRD.writeValueAsString(new Nip05Validator(profile.getNip05(), profile.getPublicKey().toString())); + String jsonString = MAPPER_BLACKBIRD.writeValueAsString( + Nip05Validator.builder() + .nip05(profile.getNip05()) + .publicKey(profile.getPublicKey().toString()) + .build()); return escapeJsonString(jsonString); } catch (JsonProcessingException ex) { throw new RuntimeException(ex); diff --git a/nostr-java-util/src/main/java/nostr/util/http/DefaultHttpClientProvider.java b/nostr-java-util/src/main/java/nostr/util/http/DefaultHttpClientProvider.java new file mode 100644 index 000000000..0f087a5af --- /dev/null +++ b/nostr-java-util/src/main/java/nostr/util/http/DefaultHttpClientProvider.java @@ -0,0 +1,18 @@ +package nostr.util.http; + +import java.net.http.HttpClient; +import java.time.Duration; + +/** + * Default implementation of {@link HttpClientProvider} using Java's HTTP client. + */ +public class DefaultHttpClientProvider implements HttpClientProvider { + + @Override + public HttpClient create(Duration connectTimeout) { + return HttpClient.newBuilder() + .connectTimeout(connectTimeout) + .build(); + } +} + diff --git a/nostr-java-util/src/main/java/nostr/util/http/HttpClientProvider.java b/nostr-java-util/src/main/java/nostr/util/http/HttpClientProvider.java new file mode 100644 index 000000000..ede1681ef --- /dev/null +++ b/nostr-java-util/src/main/java/nostr/util/http/HttpClientProvider.java @@ -0,0 +1,19 @@ +package nostr.util.http; + +import java.net.http.HttpClient; +import java.time.Duration; + +/** + * Provides {@link HttpClient} instances with configurable timeouts. + */ +public interface HttpClientProvider { + + /** + * Create a new {@link HttpClient} with the given connect timeout. + * + * @param connectTimeout the connection timeout + * @return configured HttpClient instance + */ + HttpClient create(Duration connectTimeout); +} + diff --git a/nostr-java-util/src/main/java/nostr/util/validator/Nip05Validator.java b/nostr-java-util/src/main/java/nostr/util/validator/Nip05Validator.java index 22762cb1e..d121c2904 100644 --- a/nostr-java-util/src/main/java/nostr/util/validator/Nip05Validator.java +++ b/nostr-java-util/src/main/java/nostr/util/validator/Nip05Validator.java @@ -3,70 +3,113 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.module.blackbird.BlackbirdModule; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; -import lombok.RequiredArgsConstructor; -import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; +import com.fasterxml.jackson.annotation.JsonIgnore; import nostr.util.NostrException; +import nostr.util.http.DefaultHttpClientProvider; +import nostr.util.http.HttpClientProvider; import java.io.IOException; +import java.net.IDN; import java.net.URI; import java.net.URISyntaxException; +import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; import java.util.Map; +import java.util.regex.Pattern; /** + * Validator for NIP-05 identifiers. * * @author squirrel */ @Builder -@RequiredArgsConstructor @Data @Slf4j public class Nip05Validator { private final String nip05; private final String publicKey; - - private static final String LOCAL_PART_PATTERN = "^[a-zA-Z0-9-_\\.]+$"; - - // TODO: refactor + @Builder.Default + @JsonIgnore + private final Duration connectTimeout = Duration.ofSeconds(5); + @Builder.Default + @JsonIgnore + private final Duration requestTimeout = Duration.ofSeconds(5); + @Builder.Default + @JsonIgnore + private final HttpClientProvider httpClientProvider = new DefaultHttpClientProvider(); + + private static final Pattern LOCAL_PART_PATTERN = Pattern.compile("^[a-zA-Z0-9-_\\.]+$"); + private static final Pattern DOMAIN_PATTERN = Pattern.compile("^[A-Za-z0-9.-]+(:\\d{1,5})?$"); + private static final ObjectMapper MAPPER_BLACKBIRD = JsonMapper.builder().addModule(new BlackbirdModule()).build(); + + /** + * Validate the nip05 identifier by checking the public key + * registered on the remote server. + * + * @throws NostrException if validation fails + */ public void validate() throws NostrException { - if (this.nip05 != null) { - var splited = nip05.split("@"); - var localPart = splited[0]; - var domain = splited[1]; + if (this.nip05 == null) { + return; + } + String[] split = nip05.trim().split("@"); + if (split.length != 2) { + throw new NostrException("Invalid nip05 identifier format."); + } + String localPart = split[0].trim(); + String domainPart = split[1].trim(); - if (!localPart.matches(LOCAL_PART_PATTERN)) { - throw new NostrException("Invalid syntax in nip05 attribute."); - } + if (!LOCAL_PART_PATTERN.matcher(localPart).matches()) { + throw new NostrException("Invalid syntax in nip05 attribute."); + } + if (!DOMAIN_PATTERN.matcher(domainPart).matches()) { + throw new NostrException("Invalid domain syntax in nip05 attribute."); + } - // Verify the public key + localPart = localPart.toLowerCase(Locale.ROOT); + String host; + int port = -1; + String[] hostPort = domainPart.split(":", 2); + host = IDN.toASCII(hostPort[0].toLowerCase(Locale.ROOT)); + if (hostPort.length == 2) { try { - log.debug("Validating {}@{}", localPart, domain); - validatePublicKey(domain, localPart); - } catch (URISyntaxException ex) { - log.error("Validation error", ex); - throw new NostrException(ex); + port = Integer.parseInt(hostPort[1]); + } catch (NumberFormatException ex) { + throw new NostrException("Invalid port in domain.", ex); + } + if (port < 0 || port > 65535) { + throw new NostrException("Invalid port in domain."); } } + + validatePublicKey(host, port, localPart); } - // TODO: refactor - private void validatePublicKey(String domain, String localPart) throws NostrException, URISyntaxException { + private void validatePublicKey(String host, int port, String localPart) throws NostrException { + HttpClient client = httpClientProvider.create(connectTimeout); - String strUrl = "https:///.well-known/nostr.json?name=" - .replace("", domain) - .replace("", localPart); + URI uri; + try { + uri = new URI("https", null, host, port, "/.well-known/nostr.json", + "name=" + URLEncoder.encode(localPart, StandardCharsets.UTF_8), null); + } catch (URISyntaxException ex) { + log.error("Validation error", ex); + throw new NostrException("Invalid URI for host " + host + ": " + ex.getMessage(), ex); + } - HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() - .uri(new URI(strUrl)) + .uri(uri) .GET() + .timeout(requestTimeout) .build(); HttpResponse response; @@ -77,47 +120,42 @@ private void validatePublicKey(String domain, String localPart) throws NostrExce Thread.currentThread().interrupt(); } log.error("HTTP request error", ex); - throw new NostrException(String.format("Failed to connect to %s: %s", strUrl, ex.getMessage())); + throw new NostrException(String.format("Error querying %s: %s", uri, ex.getMessage()), ex); } - if (response.statusCode() == 200) { - StringBuilder content = new StringBuilder(response.body()); + if (response.statusCode() != 200) { + throw new NostrException(String.format("Unexpected HTTP status %d from %s", response.statusCode(), uri)); + } - String pubKey = getPublicKey(content, localPart); - log.debug("Public key for {} returned by the server: [{}]", localPart, pubKey); + String pubKey = getPublicKey(response.body(), localPart); + log.debug("Public key for {} returned by the server: [{}]", localPart, pubKey); - if (pubKey != null && !pubKey.equals(publicKey)) { - throw new NostrException(String.format("Public key mismatch. Expected %s - Received: %s", publicKey, pubKey)); - } - return; + if (pubKey == null) { + throw new NostrException(String.format("No NIP-05 record for '%s' at %s", localPart, uri)); + } + if (!pubKey.equals(publicKey)) { + throw new NostrException(String.format("Public key mismatch. Expected %s - Received: %s", publicKey, pubKey)); } - - throw new NostrException(String.format("Failed to connect to %s. Status: %d", strUrl, response.statusCode())); } - @SneakyThrows - private String getPublicKey(StringBuilder content, String localPart) { - - ObjectMapper MAPPER_BLACKBIRD = JsonMapper.builder().addModule(new BlackbirdModule()).build(); - Nip05Content nip05Content = MAPPER_BLACKBIRD.readValue(content.toString(), Nip05Content.class); + private String getPublicKey(String content, String localPart) throws NostrException { + Nip05Content nip05Content; + try { + nip05Content = MAPPER_BLACKBIRD.readValue(content, Nip05Content.class); + } catch (IOException ex) { + throw new NostrException("Invalid NIP-05 response: " + ex.getMessage(), ex); + } - // Access the decoded data Map names = nip05Content.getNames(); - for (Map.Entry entry : names.entrySet()) { - String name = entry.getKey(); - String hash = entry.getValue(); - if (name.equals(localPart)) { - return hash; - } + if (names == null) { + return null; } - return null; + return names.get(localPart); } @Data - @AllArgsConstructor public static final class Nip05Obj { - private String name; - private String nip05; + private final String name; + private final String nip05; } - } diff --git a/nostr-java-util/src/test/java/nostr/util/validator/Nip05ValidatorTest.java b/nostr-java-util/src/test/java/nostr/util/validator/Nip05ValidatorTest.java index 6599c84e3..ee619e476 100644 --- a/nostr-java-util/src/test/java/nostr/util/validator/Nip05ValidatorTest.java +++ b/nostr-java-util/src/test/java/nostr/util/validator/Nip05ValidatorTest.java @@ -1,16 +1,26 @@ package nostr.util.validator; import nostr.util.NostrException; +import nostr.util.http.HttpClientProvider; import org.junit.jupiter.api.Test; +import java.io.IOException; import java.lang.reflect.Method; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Collections; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.*; public class Nip05ValidatorTest { + /* Ensures validation fails for illegal characters in the local-part. */ @Test public void testInvalidLocalPart() { Nip05Validator validator = Nip05Validator.builder() @@ -20,8 +30,9 @@ public void testInvalidLocalPart() { assertThrows(NostrException.class, validator::validate); } + /* Ensures domains containing schemes are rejected. */ @Test - public void testUnknownDomain() { + public void testInvalidDomain() { Nip05Validator validator = Nip05Validator.builder() .nip05("user@http://example.com") .publicKey("pub") @@ -29,18 +40,192 @@ public void testUnknownDomain() { assertThrows(NostrException.class, validator::validate); } + /* Validates that a matching public key passes successfully. */ + @Test + public void testSuccessfulValidation() { + HttpResponse resp = new MockHttpResponse(200, "{\"names\":{\"alice\":\"pub\"}}"); + HttpClient client = new MockHttpClient(resp); + Nip05Validator validator = Nip05Validator.builder() + .nip05("alice@example.com") + .publicKey("pub") + .httpClientProvider(new FixedHttpClientProvider(client)) + .build(); + assertDoesNotThrow(validator::validate); + } + + /* Detects when the returned public key does not match the expected one. */ + @Test + public void testMismatchedPublicKey() { + HttpResponse resp = new MockHttpResponse(200, "{\"names\":{\"alice\":\"wrong\"}}"); + HttpClient client = new MockHttpClient(resp); + Nip05Validator validator = Nip05Validator.builder() + .nip05("alice@example.com") + .publicKey("pub") + .httpClientProvider(new FixedHttpClientProvider(client)) + .build(); + assertThrows(NostrException.class, validator::validate); + } + + /* Propagates network failures with descriptive messages. */ + @Test + public void testNetworkFailure() { + HttpClient client = new MockHttpClient(new IOException("boom")); + Nip05Validator validator = Nip05Validator.builder() + .nip05("alice@example.com") + .publicKey("pub") + .httpClientProvider(new FixedHttpClientProvider(client)) + .build(); + assertThrows(NostrException.class, validator::validate); + } + + /* Verifies JSON parsing logic of the getPublicKey helper. */ @Test public void testGetPublicKeyViaReflection() throws Exception { Nip05Validator validator = Nip05Validator.builder() .nip05("user@example.com") .publicKey("pub") .build(); - Method m = Nip05Validator.class.getDeclaredMethod("getPublicKey", StringBuilder.class, String.class); + Method m = Nip05Validator.class.getDeclaredMethod("getPublicKey", String.class, String.class); m.setAccessible(true); String json = "{\"names\":{\"alice\":\"abc\"}}"; - String result = (String) m.invoke(validator, new StringBuilder(json), "alice"); + String result = (String) m.invoke(validator, json, "alice"); assertEquals("abc", result); - String missing = (String) m.invoke(validator, new StringBuilder(json), "bob"); + String missing = (String) m.invoke(validator, json, "bob"); assertNull(missing); } + + private static class FixedHttpClientProvider implements HttpClientProvider { + private final HttpClient client; + FixedHttpClientProvider(HttpClient client) { this.client = client; } + @Override + public HttpClient create(Duration connectTimeout) { return client; } + } + + private static class MockHttpClient extends HttpClient { + private final HttpResponse response; + private final IOException exception; + + MockHttpClient(HttpResponse response) { + this.response = response; + this.exception = null; + } + + MockHttpClient(IOException exception) { + this.response = null; + this.exception = exception; + } + + @Override + public Optional cookieHandler() { + return Optional.empty(); + } + + @Override + public Optional connectTimeout() { + return Optional.empty(); + } + + @Override + public Redirect followRedirects() { + return Redirect.NEVER; + } + + @Override + public Optional proxy() { + return Optional.empty(); + } + + @Override + public javax.net.ssl.SSLContext sslContext() { + return null; + } + + @Override + public javax.net.ssl.SSLParameters sslParameters() { + return null; + } + + @Override + public Optional authenticator() { + return Optional.empty(); + } + + @Override + public Optional executor() { + return Optional.empty(); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) throws IOException { + if (exception != null) { + throw exception; + } + return (HttpResponse) response; + } + + @Override + public CompletableFuture> sendAsync(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { + return CompletableFuture.failedFuture(new UnsupportedOperationException()); + } + + @Override + public CompletableFuture> sendAsync(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler, HttpResponse.PushPromiseHandler pushPromiseHandler) { + return CompletableFuture.failedFuture(new UnsupportedOperationException()); + } + } + + private static class MockHttpResponse implements HttpResponse { + private final int statusCode; + private final String body; + + MockHttpResponse(int statusCode, String body) { + this.statusCode = statusCode; + this.body = body; + } + + @Override + public int statusCode() { + return statusCode; + } + + @Override + public String body() { + return body; + } + + @Override + public HttpRequest request() { + return HttpRequest.newBuilder().uri(URI.create("https://example.com")).build(); + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(Collections.emptyMap(), (s1, s2) -> true); + } + + @Override + public URI uri() { + return URI.create("https://example.com"); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + } }