Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion nostr-java-api/src/main/java/nostr/api/NIP05.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}

Original file line number Diff line number Diff line change
@@ -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);
}

150 changes: 94 additions & 56 deletions nostr-java-util/src/main/java/nostr/util/validator/Nip05Validator.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 <local-part> syntax in nip05 attribute.");
}
if (!LOCAL_PART_PATTERN.matcher(localPart).matches()) {
throw new NostrException("Invalid <local-part> 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://<domain>/.well-known/nostr.json?name=<localPart>"
.replace("<domain>", domain)
.replace("<localPart>", 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<String> response;
Expand All @@ -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<String, String> names = nip05Content.getNames();
for (Map.Entry<String, String> 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;
}

}
Loading
Loading