From ecb801b7d553f5ff033b4858e6b5b53438d08122 Mon Sep 17 00:00:00 2001 From: stephen Date: Sat, 10 May 2025 07:48:08 -0400 Subject: [PATCH] See #1 --- .../bukkitstr/BukkitstrPlugin.java | 388 ++++++++++++++++-- 1 file changed, 351 insertions(+), 37 deletions(-) diff --git a/src/main/java/family/vanderwarker/bukkitstr/BukkitstrPlugin.java b/src/main/java/family/vanderwarker/bukkitstr/BukkitstrPlugin.java index a83b841..46c119e 100644 --- a/src/main/java/family/vanderwarker/bukkitstr/BukkitstrPlugin.java +++ b/src/main/java/family/vanderwarker/bukkitstr/BukkitstrPlugin.java @@ -11,12 +11,17 @@ import org.java_websocket.client.WebSocketClient; import org.java_websocket.handshake.ServerHandshake; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.core.JsonProcessingException; import java.net.URI; import java.net.URISyntaxException; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import java.util.logging.Logger; @@ -24,12 +29,26 @@ public class BukkitstrPlugin extends JavaPlugin implements Listener { private NostrWebSocketClient webSocketClient; private final Set authenticatedPlayers = new HashSet<>(); + private final Map pendingAuths = new ConcurrentHashMap<>(); + + // Class to hold pending authentication data + private static class PendingAuth { + final Player player; + final String pubkey; + String minecraftUsername; + boolean metadataVerified = false; + + PendingAuth(Player player, String pubkey) { + this.player = player; + this.pubkey = pubkey; + } + } @Override public void onEnable() { getLogger().info("BukkitstrPlugin enabled!"); try { - URI uri = new URI("wss://"); // Replace with your Nostr relay URL + URI uri = new URI("ws://192.168.1.14:8888"); // Replace with your Nostr relay URL webSocketClient = new NostrWebSocketClient(uri, getLogger()); webSocketClient.connect(); } catch (URISyntaxException e) { @@ -48,23 +67,139 @@ public class BukkitstrPlugin extends JavaPlugin implements Listener { @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { - if (sender instanceof Player) { - Player player = (Player) sender; - player.sendMessage("Waiting for Nostr event..."); - - webSocketClient.waitForEvent(content -> handleSpecificEvent(content, player)); - + if (!(sender instanceof Player)) { + sender.sendMessage("This command can only be used by players"); return true; } + + Player player = (Player) sender; + + if (command.getName().equalsIgnoreCase("nostrauth")) { + if (args.length < 1) { + player.sendMessage("Usage: /nostrauth "); + return true; + } + + String pubkey = args[0]; + player.sendMessage("Starting Nostr authentication for pubkey: " + pubkey); + + // Create a unique ID for this authentication attempt + UUID authId = UUID.randomUUID(); + String metadataSubId = "meta_" + authId.toString().replace("-", ""); + String authSubId = "auth_" + authId.toString().replace("-", ""); + + // Store the pending authentication + PendingAuth pendingAuth = new PendingAuth(player, pubkey); + pendingAuths.put(authId, pendingAuth); + + // First step: Check for metadata (kind 30078) + player.sendMessage("§6Checking your Nostr profile..."); + webSocketClient.subscribeToParameterizedReplaceable(metadataSubId, 30078, pubkey, "mcauth", authId, + (success, username) -> { + if (success && username != null) { + pendingAuth.metadataVerified = true; + pendingAuth.minecraftUsername = username; + + // Verify username matches + if (username.equalsIgnoreCase(player.getName())) { + player.sendMessage("§6Profile verified! Waiting for authentication event..."); + + // Now subscribe to auth events (kind 13378008) + webSocketClient.subscribeToAuthEvents(authSubId, 13378008, pubkey, authId); + } else { + player.sendMessage("§cAuthentication failed: The Minecraft username in your Nostr profile (" + + username + ") doesn't match your in-game name (" + player.getName() + ")"); + webSocketClient.closeSubscription(metadataSubId); + pendingAuths.remove(authId); + } + } else { + player.sendMessage("§cCouldn't find a Nostr profile with the Minecraft username. " + + "Make sure you've created a kind 30078 event with d=mcauth containing your username."); + webSocketClient.closeSubscription(metadataSubId); + pendingAuths.remove(authId); + } + } + ); + + return true; + } + return false; } - private void handleSpecificEvent(String content, Player player) { - if (content.equals(player.getName())) { - authenticatedPlayers.add(player); - player.sendMessage("You are now authenticated."); - } else { - player.sendMessage("Authentication failed. Username does not match."); + // Process an authentication event (kind 13378008) + private void handleAuthEvent(UUID authId, JsonNode eventNode) { + PendingAuth auth = pendingAuths.get(authId); + if (auth == null || !auth.metadataVerified) { + getLogger().warning("Received auth event for unknown auth ID or unverified metadata: " + authId); + return; + } + + Player player = auth.player; + + try { + // Get event kind to verify it's 13378008 + int kind = eventNode.path("kind").asInt(); + if (kind != 13378008) { + getLogger().warning("Expected kind 13378008, got: " + kind); + return; + } + + // Verify the pubkey matches + String eventPubkey = eventNode.path("pubkey").asText(""); + if (!eventPubkey.equals(auth.pubkey)) { + player.sendMessage("§cAuthentication failed: Event signed by wrong pubkey"); + return; + } + + // Check tags for the d tag with "mcauth" value + boolean hasMcAuthTag = false; + JsonNode tagsNode = eventNode.path("tags"); + if (tagsNode.isArray()) { + for (JsonNode tag : tagsNode) { + if (tag.isArray() && tag.size() > 1 && + "d".equals(tag.get(0).asText()) && + "mcauth".equals(tag.get(1).asText())) { + hasMcAuthTag = true; + break; + } + } + } + + if (!hasMcAuthTag) { + player.sendMessage("§cAuthentication failed: Event is missing the d=mcauth tag"); + getLogger().warning("Missing d=mcauth tag in auth event"); + return; + } + + // Get the content + String content = eventNode.path("content").asText(""); + + // Log the received event details + getLogger().info("Auth event received - Content: '" + content + "', Player: '" + player.getName() + "'"); + + // Verify content matches the player's name + if (content.equalsIgnoreCase(player.getName())) { + // Authentication successful! + authenticatedPlayers.add(player); + player.sendMessage("§a✓ Authentication successful! You can now move freely."); + player.sendMessage("§6 Please note, you'll need to reauth ~30 days"); + getLogger().info("Player " + player.getName() + " authenticated successfully with pubkey " + auth.pubkey); + + // Cleanup + String authSubId = "auth_" + authId.toString().replace("-", ""); + String metaSubId = "meta_" + authId.toString().replace("-", ""); + webSocketClient.closeSubscription(authSubId); + webSocketClient.closeSubscription(metaSubId); + pendingAuths.remove(authId); + } else { + player.sendMessage("§cAuthentication failed: Event content doesn't match your username"); + getLogger().warning("Auth event content '" + content + "' doesn't match player name '" + player.getName() + "'"); + } + + } catch (Exception e) { + getLogger().severe("Error processing auth event: " + e.getMessage()); + player.sendMessage("§cAn error occurred during authentication. Please try again."); } } @@ -73,7 +208,7 @@ public class BukkitstrPlugin extends JavaPlugin implements Listener { Player player = event.getPlayer(); if (!authenticatedPlayers.contains(player)) { event.setCancelled(true); - player.sendMessage("You need to authenticate first!"); + player.sendMessage("§cYou need to authenticate first with /nostrauth "); } } @@ -81,7 +216,7 @@ public class BukkitstrPlugin extends JavaPlugin implements Listener { private final Logger logger; private final ObjectMapper objectMapper; - private Consumer eventConsumer; + private final Map subscriptionMap = new HashMap<>(); public NostrWebSocketClient(URI serverUri, Logger logger) { super(serverUri); @@ -92,7 +227,6 @@ public class BukkitstrPlugin extends JavaPlugin implements Listener { @Override public void onOpen(ServerHandshake handshakedata) { logger.info("Connected to Nostr relay: " + getURI()); - sendInitialRequest(); } @Override @@ -114,37 +248,217 @@ public class BukkitstrPlugin extends JavaPlugin implements Listener { private void handleNostrEvent(String message) { try { JsonNode jsonNode = objectMapper.readTree(message); - if (jsonNode.isArray() && jsonNode.size() > 2) { + + // Log all messages for debugging + logger.info("Processing Nostr message: " + message); + + // Check if this is an EVENT message + if (jsonNode.isArray() && jsonNode.size() >= 3 && "EVENT".equals(jsonNode.get(0).asText())) { + String subscriptionId = jsonNode.get(1).asText(); JsonNode eventNode = jsonNode.get(2); - JsonNode tagsNode = eventNode.path("tags"); - if (tagsNode.isArray()) { - for (JsonNode tag : tagsNode) { - if (tag.isArray() && tag.size() > 1 && "d".equals(tag.get(0).asText())) { - String content = tag.get(1).asText(); - if (eventConsumer != null) { - eventConsumer.accept(content); - } + + // Log the event kind for debugging + int kind = eventNode.path("kind").asInt(); + logger.info("Received event with kind: " + kind + " for subscription: " + subscriptionId); + + // Figure out what kind of subscription this is + if (subscriptionId.startsWith("meta_")) { + logger.info("Processing as metadata event"); + handleMetadataResponse(subscriptionId, eventNode); + } else if (subscriptionId.startsWith("auth_")) { + logger.info("Processing as auth event"); + handleAuthResponse(subscriptionId, eventNode); + } + } else if (jsonNode.isArray() && jsonNode.size() >= 3 && "EOSE".equals(jsonNode.get(0).asText())) { + // End of stored events + String subscriptionId = jsonNode.get(1).asText(); + logger.info("Received EOSE for subscription: " + subscriptionId); + + if (subscriptionId.startsWith("meta_")) { + UUID authId = getAuthIdFromSubscription(subscriptionId); + if (authId != null) { + PendingAuth auth = pendingAuths.get(authId); + if (auth != null && !auth.metadataVerified) { + // No metadata found by EOSE + auth.player.sendMessage("§cNo Nostr profile found with Minecraft authentication. " + + "Please set up your profile first."); + closeSubscription(subscriptionId); + pendingAuths.remove(authId); } } - } else { - logger.warning("No 'tags' array found in the event message."); } - } else { - logger.warning("Event message is not in the expected format."); } } catch (JsonProcessingException e) { logger.severe("Failed to parse Nostr event: " + e.getMessage()); } } - - public void waitForEvent(Consumer eventConsumer) { - this.eventConsumer = eventConsumer; + + private void handleMetadataResponse(String subscriptionId, JsonNode eventNode) { + UUID authId = getAuthIdFromSubscription(subscriptionId); + if (authId == null) return; + + try { + // Check if this is kind 30078 + int kind = eventNode.path("kind").asInt(); + if (kind != 30078) return; + + // Look for d tag with "mcauth" value + String dValue = null; + JsonNode tagsNode = eventNode.path("tags"); + if (tagsNode.isArray()) { + for (JsonNode tag : tagsNode) { + if (tag.isArray() && tag.size() > 1 && + "d".equals(tag.get(0).asText())) { + dValue = tag.get(1).asText(); + break; + } + } + } + + if (!"mcauth".equals(dValue)) { + logger.info("Skipping 30078 event with d=" + dValue + " (looking for d=mcauth)"); + return; + } + + // Parse content for the Minecraft username + String content = eventNode.path("content").asText(""); + String username = content.trim(); + + if (username.isEmpty()) { + logger.warning("Empty Minecraft username in 30078 event"); + return; + } + + PendingAuth auth = pendingAuths.get(authId); + if (auth != null) { + // Callback with success + auth.player.sendMessage("§6Found Nostr profile with Minecraft username: " + username); + auth.metadataVerified = true; + auth.minecraftUsername = username; + + // Check if username matches + if (username.equalsIgnoreCase(auth.player.getName())) { + auth.player.sendMessage("§6Username verified! Waiting for authentication event..."); + + // Now subscribe to auth events (kind 13378008) + String authSubId = "auth_" + authId.toString().replace("-", ""); + subscribeToAuthEvents(authSubId, 13378008, auth.pubkey, authId); + } else { + auth.player.sendMessage("§cAuthentication failed: The Minecraft username in your Nostr profile (" + + username + ") doesn't match your in-game name (" + auth.player.getName() + ")"); + closeSubscription(subscriptionId); + pendingAuths.remove(authId); + } + } + + } catch (Exception e) { + logger.severe("Error handling metadata response: " + e.getMessage()); + } } - - private void sendInitialRequest() { - String initialRequest = "[\"REQ\",\"mc_auth\",{\"kinds\":[13378008],\"limit\":0}]"; - send(initialRequest); - logger.info("Sent initial request to Nostr relay: " + initialRequest); + + private void handleAuthResponse(String subscriptionId, JsonNode eventNode) { + UUID authId = getAuthIdFromSubscription(subscriptionId); + if (authId != null) { + // Log the event details for debugging + int kind = eventNode.path("kind").asInt(); + String pubkey = eventNode.path("pubkey").asText(""); + String content = eventNode.path("content").asText(""); + + logger.info("Auth event received - Kind: " + kind + ", Pubkey: " + pubkey + ", Content: '" + content + "'"); + + // Only process kind 13378008 events for authentication + if (kind == 13378008) { + logger.info("Processing kind 13378008 event for authentication"); + handleAuthEvent(authId, eventNode); + } else { + logger.info("Ignoring event with kind " + kind + " (expecting 13378008)"); + } + } else { + logger.warning("Cannot find auth ID for subscription: " + subscriptionId); + } + } + + private UUID getAuthIdFromSubscription(String subscriptionId) { + // Extract authId from subscription ID + String idPart = subscriptionId.substring(subscriptionId.indexOf('_') + 1); + try { + return UUID.fromString( + idPart.replaceFirst( + "(\\p{XDigit}{8})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}+)", + "$1-$2-$3-$4-$5" + ) + ); + } catch (IllegalArgumentException e) { + logger.warning("Invalid UUID in subscription ID: " + idPart); + return null; + } + } + + // Subscribe to parameterized replaceable events (for metadata) + public void subscribeToParameterizedReplaceable(String subscriptionId, int kind, String pubkey, + String dValue, UUID authId, + MetadataCallback callback) { + try { + // Create proper filter for Nostr - using array notation for kinds and correct #d tag format + String filterJson = "{" + + "\"kinds\":[" + kind + "]," + + "\"authors\":[\"" + pubkey + "\"]," + + "\"#d\":[\"" + dValue + "\"]" + + "}"; + + JsonNode filter = objectMapper.readTree(filterJson); + String reqMessage = "[\"REQ\",\"" + subscriptionId + "\"," + filter.toString() + "]"; + send(reqMessage); + logger.info("Sent metadata subscription: " + reqMessage); + + // Store the subscription ID mapping + subscriptionMap.put(subscriptionId, authId); + + } catch (Exception e) { + logger.severe("Failed to create metadata subscription: " + e.getMessage()); + } + } + + // Subscribe to authentication events + public void subscribeToAuthEvents(String subscriptionId, int kind, String pubkey, UUID authId) { + try { + // Create proper filter format for Nostr - using array notation for #d tag + String filterJson = "{" + + "\"kinds\":[" + kind + "]," + + "\"authors\":[\"" + pubkey + "\"]," + + "\"#d\":[\"mcauth\"]" + + "}"; + + JsonNode filter = objectMapper.readTree(filterJson); + String reqMessage = "[\"REQ\",\"" + subscriptionId + "\"," + filter.toString() + "]"; + send(reqMessage); + logger.info("Sent auth subscription: " + reqMessage); + + // Store the subscription ID mapping + subscriptionMap.put(subscriptionId, authId); + + // Notify player + PendingAuth auth = pendingAuths.get(authId); + if (auth != null) { + auth.player.sendMessage("§6Waiting for authentication event (kind 13378008)..."); + auth.player.sendMessage("§6Please sign an event with your Nostr client."); + } + + } catch (Exception e) { + logger.severe("Failed to create auth subscription: " + e.getMessage()); + } + } + + public void closeSubscription(String subscriptionId) { + String closeMessage = "[\"CLOSE\",\"" + subscriptionId + "\"]"; + send(closeMessage); + logger.info("Closed subscription: " + closeMessage); + subscriptionMap.remove(subscriptionId); } } + + // Interface for metadata callback + interface MetadataCallback { + void onResult(boolean success, String username); + } }