diff --git a/app/src/main/java/io/xpipe/app/core/mode/BaseMode.java b/app/src/main/java/io/xpipe/app/core/mode/BaseMode.java index 20cef2913..d09df6cf1 100644 --- a/app/src/main/java/io/xpipe/app/core/mode/BaseMode.java +++ b/app/src/main/java/io/xpipe/app/core/mode/BaseMode.java @@ -17,6 +17,7 @@ import io.xpipe.app.icon.SystemIconManager; import io.xpipe.app.issue.ErrorEvent; import io.xpipe.app.issue.TrackEvent; import io.xpipe.app.prefs.AppPrefs; +import io.xpipe.app.prefs.KeePassClient; import io.xpipe.app.resources.*; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStorageSyncHandler; @@ -174,6 +175,7 @@ public class BaseMode extends OperationMode { ProcessControlProvider.get().reset(); AppPrefs.reset(); AppBeaconServer.reset(); + KeePassClient.reset(); StoreViewState.reset(); AppLayoutModel.reset(); AppTheme.reset(); diff --git a/app/src/main/java/io/xpipe/app/prefs/KeePassAssociationKey.java b/app/src/main/java/io/xpipe/app/prefs/KeePassAssociationKey.java new file mode 100644 index 000000000..98416ee7f --- /dev/null +++ b/app/src/main/java/io/xpipe/app/prefs/KeePassAssociationKey.java @@ -0,0 +1,14 @@ +package io.xpipe.app.prefs; + +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +@Value +@Jacksonized +@Builder +public class KeePassAssociationKey { + String id; + String key; + String hash; +} diff --git a/app/src/main/java/io/xpipe/app/prefs/KeePassClient.java b/app/src/main/java/io/xpipe/app/prefs/KeePassClient.java new file mode 100644 index 000000000..6e6699069 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/prefs/KeePassClient.java @@ -0,0 +1,107 @@ +package io.xpipe.app.prefs; + +import io.xpipe.app.core.AppCache; +import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.util.LocalShell; +import io.xpipe.app.util.WindowsRegistry; +import io.xpipe.core.process.OsType; +import lombok.SneakyThrows; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +public class KeePassClient { + + private static KeePassNativeClient client; + + @SneakyThrows + public static String receive(String key) { + var client = getOrCreate(); + client.getDatabaseGroups(); + return client.getLogins("abc"); + } + + public static void reset() { + if (client != null) { + client.disconnect(); + client = null; + } + } + + private static synchronized KeePassNativeClient getOrCreate() throws Exception { + if (client == null) { + var found = findKeePassProxy(); + if (found.isEmpty()) { + throw ErrorEvent.expected(new UnsupportedOperationException("No KeePassXC installation was found")); + } + + var c = new KeePassNativeClient(found.get()); + c.connect(); + c.exchangeKeys(); + KeePassAssociationKey cached = AppCache.getNonNull("keepassxc-association", KeePassAssociationKey.class, () -> null); + if (cached != null) { + c.useExistingAssociationKey(cached); + try { + c.testAssociation(); + } catch (Exception e) { + ErrorEvent.fromThrowable(e).handle(); + c.useExistingAssociationKey(null); + cached = null; + } + } + if (cached == null) { + c.associate(); + c.testAssociation(); + AppCache.update("keepassxc-association", c.getAssociationKey()); + } + client = c; + } + + return client; + } + + private static Optional findKeePassProxy() { + try (var sc = LocalShell.getShell().start()) { + var found = sc.view().findProgram("keepassxc-proxy"); + if (found.isPresent()) { + return found.map(filePath -> filePath.asLocalPath()); + } + + } catch (Exception e) { + ErrorEvent.fromThrowable(e).handle(); + } + + return switch (OsType.getLocal()) { + case OsType.Linux linux -> { + var paths = List.of(Path.of("/usr/bin/keepassxc-proxy"), Path.of("/usr/local/bin/keepassxc-proxy")); + yield paths.stream().filter(path -> Files.exists(path)).findFirst(); + } + case OsType.MacOs macOs -> { + var paths = List.of(Path.of("/Applications/KeePassXC.app/Contents/MacOS/keepassxc-proxy")); + yield paths.stream().filter(path -> Files.exists(path)).findFirst(); + } + case OsType.Windows windows -> { + try { + var foundKey = WindowsRegistry.local() + .findKeyForEqualValueMatchRecursive( + WindowsRegistry.HKEY_LOCAL_MACHINE, + "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", + "https://keepassxc.org"); + if (foundKey.isPresent()) { + var installKey = WindowsRegistry.local() + .readStringValueIfPresent( + foundKey.get().getHkey(), foundKey.get().getKey(), "InstallLocation"); + if (installKey.isPresent()) { + yield installKey.map(p -> p + "\\keepassxc-proxy.exe").map(Path::of); + } + } + } catch (Exception e) { + ErrorEvent.fromThrowable(e).handle(); + } + yield Optional.empty(); + } + }; + } +} diff --git a/app/src/main/java/io/xpipe/app/prefs/KeePassNativeClient.java b/app/src/main/java/io/xpipe/app/prefs/KeePassNativeClient.java new file mode 100644 index 000000000..41fb30a88 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/prefs/KeePassNativeClient.java @@ -0,0 +1,836 @@ +package io.xpipe.app.prefs; + +import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.util.ThreadHelper; +import lombok.Getter; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Client for communicating with KeePassXC using the native messaging protocol. + * This implementation communicates with the actual running KeePassXC-proxy process + * via stdin and stdout. + * + * Native messaging uses length-prefixed JSON messages over stdin/stdout. + */ +public class KeePassNativeClient { + + // Default timeouts for different operations (milliseconds) + private static final long TIMEOUT_ASSOCIATE = 30000; // Associate needs user interaction + private static final long TIMEOUT_GET_LOGINS = 5000; // Getting logins is usually fast + private static final long TIMEOUT_TEST_ASSOCIATE = 2000; // Testing association is quick + private static final long TIMEOUT_GET_DATABASE_GROUPS = 3000; // Getting database groups + + private final Path proxyExecutable; + private Process process; + private String clientId; + private TweetNaClHelper.KeyPair keyPair; + private byte[] serverPublicKey; + private boolean connected = false; + private boolean associated = false; + private Thread responseHandler; + @Getter + private KeePassAssociationKey associationKey; + + // Message buffer for handling requests/responses + private final MessageBuffer messageBuffer = new MessageBuffer(); + private final Object responseNotifier = new Object(); + + // Flag to indicate if key exchange is in progress + private volatile boolean keyExchangeInProgress = false; + + public KeePassNativeClient(Path proxyExecutable) {this.proxyExecutable = proxyExecutable;} + + public void useExistingAssociationKey(KeePassAssociationKey key) { + this.associationKey = key; + } + + /** + * Connects to KeePassXC via the provided input and output streams. + * In a real application, these would be the streams connecting to KeePassXC. + * + * @return True if connection was successful, false otherwise + * @throws IOException If there's an error connecting to KeePassXC + */ + public void connect() throws IOException { + // Generate a random client ID (24 bytes base64 encoded) + this.clientId = TweetNaClHelper.encodeBase64(TweetNaClHelper.randomBytes(TweetNaClHelper.NONCE_SIZE)); + + // Generate actual cryptographic keys + this.keyPair = TweetNaClHelper.generateKeyPair(); + + var pb = new ProcessBuilder(List.of(proxyExecutable.toString())); + this.process = pb.start(); + + // Start a thread to handle responses + responseHandler = new Thread(this::handleResponses); + responseHandler.setDaemon(true); + responseHandler.start(); + + connected = true; + } + + /** + * Performs a key exchange with KeePassXC. + * + * @return True if the key exchange was successful, false otherwise + * @throws IOException If there's an error communicating with KeePassXC + */ + public void exchangeKeys() throws IOException { + // Generate a nonce + byte[] nonceBytes = TweetNaClHelper.randomBytes(TweetNaClHelper.NONCE_SIZE); + String nonce = TweetNaClHelper.encodeBase64(nonceBytes); + + // Convert our public key to base64 + String publicKeyB64 = TweetNaClHelper.encodeBase64(keyPair.getPublicKey()); + + // Build the key exchange message - NOTE: This is NOT encrypted + String requestId = UUID.randomUUID().toString(); + Map messageMap = new HashMap<>(); + messageMap.put("action", "change-public-keys"); + messageMap.put("publicKey", publicKeyB64); + messageMap.put("nonce", nonce); + messageMap.put("clientID", clientId); + messageMap.put("requestId", requestId); + + // Convert to JSON string + String keyExchangeMessage = mapToJson(messageMap); + + // Send the message directly + long startTime = System.currentTimeMillis(); + sendNativeMessage(keyExchangeMessage); + + // Wait for a direct response rather than using CompletableFuture + // This is a special case because we can't use the encryption yet + long timeout = 3000; // 3 seconds for key exchange + + while (System.currentTimeMillis() - startTime < timeout) { + if (process.getInputStream().available() > 0) { + String response = receiveNativeMessage(); + if (response.contains("change-public-keys")) { + // Use regex to extract the public key to avoid any JSON parsing issues + Pattern pattern = Pattern.compile("\"publicKey\":\"([^\"]+)\""); + Matcher matcher = pattern.matcher(response); + + if (matcher.find()) { + String serverPubKeyB64 = matcher.group(1); + + // Store the server's public key + this.serverPublicKey = TweetNaClHelper.decodeBase64(serverPubKeyB64); + + // Check for success in the response + boolean success = response.contains("\"success\":\"true\""); + if (!success) { + throw new IllegalStateException("Key exchange failed"); + } else { + return; + } + } + } + } + + // Small sleep to prevent CPU hogging + ThreadHelper.sleep(50); + } + + throw new IllegalStateException("Key exchanged timed out"); + } + + /** + * Tests the association with KeePassXC. + * + * @return True if associated, false otherwise + * @throws IOException If there's an error communicating with KeePassXC + */ + public void testAssociation() throws IOException { + if (associationKey == null) { + // We need to do an association first + throw ErrorEvent.expected(new IllegalStateException("KeePassXC association failed or was cancelled")); + } + + // Generate a nonce + String nonce = TweetNaClHelper.encodeBase64(TweetNaClHelper.randomBytes(TweetNaClHelper.NONCE_SIZE)); + + // Create the unencrypted message + Map messageData = new HashMap<>(); + messageData.put("action", "test-associate"); + messageData.put("id", associationKey.getId()); + messageData.put("key", associationKey.getKey()); + + // Encrypt the message + String encryptedMessage = encrypt(messageData, nonce); + if (encryptedMessage == null) { + throw new IllegalStateException("Failed to encrypt test-associate message"); + } + + // Build the request + Map request = new HashMap<>(); + request.put("action", "test-associate"); + request.put("message", encryptedMessage); + request.put("nonce", nonce); + request.put("clientID", clientId); + + String requestJson = mapToJson(request); + + // Send the request + String responseJson = sendRequest("test-associate", requestJson, TIMEOUT_TEST_ASSOCIATE); + if (responseJson == null) { + throw new IllegalStateException("No response received from associated instance"); + } + + // Parse and decrypt the response + Map responseMap = jsonToMap(responseJson); + if (responseMap.containsKey("message") && responseMap.containsKey("nonce")) { + String encryptedResponse = (String) responseMap.get("message"); + String responseNonce = (String) responseMap.get("nonce"); + + String decryptedResponse = decrypt(encryptedResponse, responseNonce); + if (decryptedResponse != null) { + Map parsedResponse = jsonToMap(decryptedResponse); + boolean success = parsedResponse.containsKey("success") && + "true".equals(parsedResponse.get("success").toString()); + + if (success) { + associated = true; + } else { + throw new IllegalStateException("KeePassXC association failed"); + } + } + } + } + + /** + * Retrieves credentials from KeePassXC. + * + * @param url The URL to get credentials for + * @return The response JSON, or null if failed + * @throws IOException If there's an error communicating with KeePassXC + */ + public String getLogins(String url) throws IOException { + // Generate a nonce + String nonce = TweetNaClHelper.encodeBase64(TweetNaClHelper.randomBytes(TweetNaClHelper.NONCE_SIZE)); + + // Create the unencrypted message + Map messageData = new HashMap<>(); + messageData.put("action", "get-logins"); + messageData.put("url", url); + + // Add the keys + Map keyData = new HashMap<>(); + keyData.put("id", associationKey.getId()); + keyData.put("key", associationKey.getKey()); + + messageData.put("keys", new Map[] { keyData }); + + // Encrypt the message + String encryptedMessage = encrypt(messageData, nonce); + if (encryptedMessage == null) { + return null; + } + + // Build the request + Map request = new HashMap<>(); + request.put("action", "get-logins"); + request.put("message", encryptedMessage); + request.put("nonce", nonce); + request.put("clientID", clientId); + + String requestJson = mapToJson(request); + System.out.println("Sending get-logins message: " + requestJson); + + // Send the request + String responseJson = sendRequest("get-logins", requestJson, TIMEOUT_GET_LOGINS); + if (responseJson == null) { + return null; + } + + // Parse and decrypt the response + try { + Map responseMap = jsonToMap(responseJson); + if (responseMap.containsKey("message") && responseMap.containsKey("nonce")) { + String encryptedResponse = (String) responseMap.get("message"); + String responseNonce = (String) responseMap.get("nonce"); + + return decrypt(encryptedResponse, responseNonce); + } + } catch (Exception e) { + System.err.println("Error processing get-logins response: " + e.getMessage()); + } + + return null; + } + + /** + * Disconnects from KeePassXC. + */ + public void disconnect() { + if (responseHandler != null) { + responseHandler.interrupt(); + responseHandler = null; + } + + process.destroy(); + process = null; + } + + /** + * Sends a request to KeePassXC and waits for a response. + * + * @param action The action being performed (e.g., "associate", "get-logins") + * @param message The JSON message to send + * @param timeout The timeout in milliseconds + * @return The response JSON, or null if timed out + * @throws IOException If there's an error communicating with KeePassXC + */ + private String sendRequest(String action, String message, long timeout) throws IOException { + String requestId = extractRequestId(message); + if (requestId == null) { + // If no requestId in the message, generate one for tracking + requestId = UUID.randomUUID().toString(); + } + + // Create a completable future for this request + CompletableFuture responseFuture = new CompletableFuture<>(); + + // Create a pending request and add it to the message buffer + PendingRequest request = new PendingRequest(requestId, action, responseFuture, timeout); + messageBuffer.addRequest(request); + + // Send the message + sendNativeMessage(message); + + // Notify the response handler that we've sent a message + synchronized (responseNotifier) { + responseNotifier.notify(); + } + + try { + // Wait for the response with the specified timeout + return responseFuture.get(timeout, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + System.err.println("Request interrupted: " + e.getMessage()); + return null; + } catch (ExecutionException e) { + System.err.println("Error in request execution: " + e.getMessage()); + return null; + } catch (TimeoutException e) { + System.err.println("Request timed out after " + timeout + "ms: " + action); + return null; + } finally { + // Clean up timed-out requests + messageBuffer.cleanupTimedOutRequests(); + } + } + + /** + * Extracts the requestId from a JSON message. + * + * @param message The JSON message + * @return The requestId, or null if not found + */ + private String extractRequestId(String message) { + return MessageBuffer.extractRequestId(message); + } + + /** + * Continuously reads and processes responses from KeePassXC. + */ + private void handleResponses() { + try { + while (!Thread.currentThread().isInterrupted()) { + // If key exchange is in progress, skip normal message handling + if (keyExchangeInProgress) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + continue; + } + + // Check if there's anything to read + boolean hasData = false; + try { + hasData = process.getInputStream().available() > 0; + } catch (IOException e) { + System.err.println("Error checking input stream: " + e.getMessage()); + continue; + } + + if (hasData) { + try { + String response = receiveNativeMessage(); + if (response != null) { + processResponse(response); + } + } catch (IOException e) { + System.err.println("Error reading response: " + e.getMessage()); + } + } else { + // If nothing to read, wait efficiently + try { + synchronized (responseNotifier) { + responseNotifier.wait(100); // Wait up to 100ms for notification + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + + // Periodically check for timed-out requests + messageBuffer.cleanupTimedOutRequests(); + } + } catch (Exception e) { + System.err.println("Error in response handler: " + e.getMessage()); + } + } + + /** + * Process a response from KeePassXC. + * + * @param response The JSON response + */ + private void processResponse(String response) { + System.out.println("Received response: " + response); + + try { + // Extract action + String action = MessageBuffer.extractAction(response); + + // Special handling for action-specific responses + if ("database-locked".equals(action) || "database-unlocked".equals(action)) { + System.out.println("Database state changed: " + action); + // Update state based on the action + if ("database-locked".equals(action)) { + associated = false; + } + + // Notify any waiting requests + messageBuffer.handleResponse(response); + return; + } + + // Standard response handling - use the message buffer to complete the appropriate request + int completedCount = messageBuffer.handleResponse(response); + if (completedCount == 0) { + System.out.println("Warning: Response did not match any pending request: " + response); + } + } catch (Exception e) { + System.err.println("Error processing response: " + e.getMessage()); + } + } + + /** + * Sends a message to KeePassXC using the native messaging protocol. + * The message is prefixed with a 32-bit length (little-endian). + * + * @param message The JSON message to send + * @throws IOException If there's an error writing to the stream + */ + private void sendNativeMessage(String message) throws IOException { + byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8); + int length = messageBytes.length; + + // Create a ByteBuffer with length in little-endian format + ByteBuffer lengthBuffer = ByteBuffer.allocate(4); + lengthBuffer.order(ByteOrder.LITTLE_ENDIAN); + lengthBuffer.putInt(length); + + process.getOutputStream().write(lengthBuffer.array()); + process.getOutputStream().write(messageBytes); + process.getOutputStream().flush(); + } + + /** + * Receives a message from KeePassXC using the native messaging protocol. + * The message is prefixed with a 32-bit length (little-endian). + * + * @return The received JSON message as a string, or null if reading failed + * @throws IOException If there's an error reading from the stream + */ + private String receiveNativeMessage() throws IOException { + // Read the length prefix (4 bytes, little-endian) + byte[] lengthBytes = new byte[4]; + int bytesRead = process.getInputStream().read(lengthBytes); + if (bytesRead != 4) { + throw new IOException("Error reading received message"); + } + + // Convert bytes to integer (little-endian) + ByteBuffer lengthBuffer = ByteBuffer.wrap(lengthBytes); + lengthBuffer.order(ByteOrder.LITTLE_ENDIAN); + int messageLength = lengthBuffer.getInt(); + + // Read the actual message + byte[] messageBytes = new byte[messageLength]; + var read = process.getInputStream().read(messageBytes); + if (read != messageLength) { + throw new IOException("Received message with " + read + " bytes but expected " + messageBytes.length); + } + + return new String(messageBytes, StandardCharsets.UTF_8); + } + + /** + * Gets the database groups from KeePassXC. + * + * @return The JSON string containing the groups structure, or null if failed + * @throws IOException If there's an error communicating with KeePassXC + */ + public String getDatabaseGroups() throws IOException { + if (!connected) { + throw new IllegalStateException("Not connected to KeePassXC"); + } + + // Generate a nonce + String nonce = TweetNaClHelper.encodeBase64(TweetNaClHelper.randomBytes(TweetNaClHelper.NONCE_SIZE)); + + // Create the unencrypted message + Map messageData = new HashMap<>(); + messageData.put("action", "get-database-groups"); + + // Encrypt the message + String encryptedMessage = encrypt(messageData, nonce); + if (encryptedMessage == null) { + System.err.println("Failed to encrypt get-database-groups message"); + return null; + } + + // Build the request + Map request = new HashMap<>(); + request.put("action", "get-database-groups"); + request.put("message", encryptedMessage); + request.put("nonce", nonce); + request.put("clientID", clientId); + + String requestJson = mapToJson(request); + System.out.println("Sending get-database-groups message: " + requestJson); + + // Send the request + String responseJson = sendRequest("get-database-groups", requestJson, TIMEOUT_GET_DATABASE_GROUPS); + if (responseJson == null) { + System.err.println("No response received from get-database-groups"); + return null; + } + + // Parse and decrypt the response + try { + Map responseMap = jsonToMap(responseJson); + if (responseMap.containsKey("message") && responseMap.containsKey("nonce")) { + String encryptedResponse = (String) responseMap.get("message"); + String responseNonce = (String) responseMap.get("nonce"); + + String decryptedResponse = decrypt(encryptedResponse, responseNonce); + if (decryptedResponse != null) { + System.out.println("Received decrypted get-database-groups response: " + decryptedResponse); + return decryptedResponse; + } else { + System.err.println("Failed to decrypt get-database-groups response"); + } + } + } catch (Exception e) { + System.err.println("Error processing get-database-groups response: " + e.getMessage()); + } + + return null; + } + + /** + * Encrypts a message for sending to KeePassXC. + * + * @param message The message to encrypt + * @param nonce The nonce to use for encryption + * @return The encrypted message, or null if encryption failed + */ + private String encrypt(Map message, String nonce) { + if (serverPublicKey == null) { + System.err.println("Server public key not available for encryption"); + return null; + } + + try { + String messageJson = mapToJson(message); + byte[] messageBytes = messageJson.getBytes(StandardCharsets.UTF_8); + byte[] nonceBytes = TweetNaClHelper.decodeBase64(nonce); + + byte[] encrypted = TweetNaClHelper.box( + messageBytes, + nonceBytes, + serverPublicKey, + keyPair.getSecretKey() + ); + + if (encrypted == null) { + System.err.println("Encryption failed"); + return null; + } + + return TweetNaClHelper.encodeBase64(encrypted); + } catch (Exception e) { + System.err.println("Error during encryption: " + e.getMessage()); + return null; + } + } + + /** + * Decrypts a message received from KeePassXC. + * + * @param encryptedMessage The encrypted message + * @param nonce The nonce used for encryption + * @return The decrypted message, or null if decryption failed + */ + private String decrypt(String encryptedMessage, String nonce) { + if (serverPublicKey == null) { + System.err.println("Server public key not available for decryption"); + return null; + } + + try { + byte[] messageBytes = TweetNaClHelper.decodeBase64(encryptedMessage); + byte[] nonceBytes = TweetNaClHelper.decodeBase64(nonce); + + byte[] decrypted = TweetNaClHelper.boxOpen( + messageBytes, + nonceBytes, + serverPublicKey, + keyPair.getSecretKey() + ); + + if (decrypted == null) { + System.err.println("Decryption failed"); + return null; + } + + return new String(decrypted, StandardCharsets.UTF_8); + } catch (Exception e) { + System.err.println("Error during decryption: " + e.getMessage()); + return null; + } + } + + /** + * Associate with KeePassXC. + * + * @return True if successful, false otherwise + * @throws IOException If there's an error communicating with KeePassXC + */ + public boolean associate() throws IOException { + // Generate a key pair for identification + TweetNaClHelper.KeyPair idKeyPair = TweetNaClHelper.generateKeyPair(); + + // Generate a nonce + String nonce = TweetNaClHelper.encodeBase64(TweetNaClHelper.randomBytes(TweetNaClHelper.NONCE_SIZE)); + + // Create the unencrypted message + Map messageData = new HashMap<>(); + messageData.put("action", "associate"); + messageData.put("key", TweetNaClHelper.encodeBase64(keyPair.getPublicKey())); + messageData.put("idKey", TweetNaClHelper.encodeBase64(idKeyPair.getPublicKey())); + + // Encrypt the message + String encryptedMessage = encrypt(messageData, nonce); + if (encryptedMessage == null) { + return false; + } + + // Build the request + Map request = new HashMap<>(); + request.put("action", "associate"); + request.put("message", encryptedMessage); + request.put("nonce", nonce); + request.put("clientID", clientId); + + String requestJson = mapToJson(request); + System.out.println("Sending associate message: " + requestJson); + + // Send the request using longer timeout as it requires user interaction + String responseJson = sendRequest("associate", requestJson, TIMEOUT_ASSOCIATE); + if (responseJson == null) { + return false; + } + + // Parse and decrypt the response + try { + Map responseMap = jsonToMap(responseJson); + if (responseMap.containsKey("message") && responseMap.containsKey("nonce")) { + String encryptedResponse = (String) responseMap.get("message"); + String responseNonce = (String) responseMap.get("nonce"); + + String decryptedResponse = decrypt(encryptedResponse, responseNonce); + if (decryptedResponse != null) { + Map parsedResponse = jsonToMap(decryptedResponse); + boolean success = parsedResponse.containsKey("success") && + "true".equals(parsedResponse.get("success").toString()); + + if (success && parsedResponse.containsKey("id") && parsedResponse.containsKey("hash")) { + String id = (String) parsedResponse.get("id"); + String hash = (String) parsedResponse.get("hash"); + + associationKey = new KeePassAssociationKey(id, TweetNaClHelper.encodeBase64(idKeyPair.getPublicKey()), hash); + associated = true; + + System.out.println("Association successful"); + System.out.println("Database ID: " + id); + System.out.println("Database hash: " + hash); + + return true; + } + } + } + } catch (Exception e) { + System.err.println("Error processing associate response: " + e.getMessage()); + } + + return false; + } + + /** + * Convert a map to a JSON string. + */ + private String mapToJson(Map map) { + StringBuilder sb = new StringBuilder(); + sb.append("{"); + + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + sb.append(","); + } + first = false; + + sb.append("\"").append(entry.getKey()).append("\":"); + + Object value = entry.getValue(); + if (value instanceof String) { + sb.append("\"").append(escapeJsonString((String) value)).append("\""); + } else if (value instanceof Number || value instanceof Boolean) { + sb.append(value); + } else if (value instanceof Map[]) { + sb.append("["); + Map[] maps = (Map[]) value; + for (int i = 0; i < maps.length; i++) { + if (i > 0) { + sb.append(","); + } + sb.append(mapToJson(maps[i])); + } + sb.append("]"); + } else if (value == null) { + sb.append("null"); + } else { + sb.append("\"").append(escapeJsonString(value.toString())).append("\""); + } + } + + sb.append("}"); + return sb.toString(); + } + + /** + * Escape special characters in a JSON string. + */ + private String escapeJsonString(String s) { + if (s == null) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char ch = s.charAt(i); + switch (ch) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + default: + sb.append(ch); + } + } + return sb.toString(); + } + + /** + * Convert a JSON string to a map. + */ + private Map jsonToMap(String json) { + Map map = new HashMap<>(); + + try { + // Use regex to extract key-value pairs + Pattern pattern = Pattern.compile("\"([^\"]+)\"\\s*:\\s*(\"[^\"]*\"|\\d+|true|false|null|\\{[^}]*\\}|\\[[^\\]]*\\])"); + Matcher matcher = pattern.matcher(json); + + while (matcher.find()) { + String key = matcher.group(1); + String valueStr = matcher.group(2); + + // Parse the value based on its format + Object value; + if (valueStr.startsWith("\"") && valueStr.endsWith("\"")) { + // String value + value = valueStr.substring(1, valueStr.length() - 1); + } else if ("true".equals(valueStr) || "false".equals(valueStr)) { + // Boolean value + value = Boolean.parseBoolean(valueStr); + } else if ("null".equals(valueStr)) { + // Null value + value = null; + } else { + try { + // Number value + value = Integer.parseInt(valueStr); + } catch (NumberFormatException e1) { + try { + value = Double.parseDouble(valueStr); + } catch (NumberFormatException e2) { + // Just use the string as is + value = valueStr; + } + } + } + + map.put(key, value); + } + } catch (Exception e) { + System.err.println("Error parsing JSON: " + e.getMessage()); + } + + return map; + } +} \ No newline at end of file diff --git a/app/src/main/java/io/xpipe/app/prefs/MessageBuffer.java b/app/src/main/java/io/xpipe/app/prefs/MessageBuffer.java new file mode 100644 index 000000000..cbd8043c2 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/prefs/MessageBuffer.java @@ -0,0 +1,312 @@ +package io.xpipe.app.prefs; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Manages pending requests to KeePassXC. + * This class tracks all pending requests and provides methods to add, complete, and cancel requests. + */ +public class MessageBuffer { + private final Map requestsById; + private final Map> requestsByAction; + private final Object lock = new Object(); + + /** + * Creates a new message buffer. + */ + public MessageBuffer() { + this.requestsById = new ConcurrentHashMap<>(); + this.requestsByAction = new ConcurrentHashMap<>(); + } + + /** + * Adds a new pending request to the buffer. + * + * @param request The request to add + */ + public void addRequest(PendingRequest request) { + synchronized (lock) { + requestsById.put(request.getRequestId(), request); + + requestsByAction.computeIfAbsent(request.getAction(), k -> new ArrayList<>()) + .add(request); + } + } + + /** + * Gets a request by its ID. + * + * @param requestId The request ID + * @return The request, or null if not found + */ + public PendingRequest getRequestById(String requestId) { + synchronized (lock) { + return requestsById.get(requestId); + } + } + + /** + * Gets all pending requests for a specific action. + * + * @param action The action + * @return A list of pending requests, or an empty list if none found + */ + public List getRequestsByAction(String action) { + synchronized (lock) { + List requests = requestsByAction.get(action); + if (requests == null) { + return new ArrayList<>(); + } + return new ArrayList<>(requests); // Return a copy to avoid concurrent modification + } + } + + /** + * Completes a request with the given response. + * + * @param requestId The request ID + * @param response The response from KeePassXC + * @return True if the request was completed, false if not found or already completed + */ + public boolean completeRequest(String requestId, String response) { + synchronized (lock) { + PendingRequest request = requestsById.get(requestId); + if (request == null) { + return false; + } + + boolean completed = request.complete(response); + if (completed) { + removeRequest(request); + } + return completed; + } + } + + /** + * Completes all pending requests for a specific action. + * This is useful for action-specific responses that don't include a request ID. + * + * @param action The action + * @param response The response from KeePassXC + * @return The number of requests that were completed + */ + public int completeRequestsByAction(String action, String response) { + synchronized (lock) { + List requests = requestsByAction.get(action); + if (requests == null || requests.isEmpty()) { + return 0; + } + + int count = 0; + List completedRequests = new ArrayList<>(); + + for (PendingRequest request : requests) { + if (request.complete(response)) { + completedRequests.add(request); + count++; + } + } + + // Remove completed requests + for (PendingRequest request : completedRequests) { + removeRequest(request); + } + + return count; + } + } + + /** + * Times out a request. + * + * @param requestId The request ID + * @return True if the request was timed out, false if not found or already completed + */ + public boolean timeoutRequest(String requestId) { + synchronized (lock) { + PendingRequest request = requestsById.get(requestId); + if (request == null) { + return false; + } + + boolean timedOut = request.timeout(); + if (timedOut) { + removeRequest(request); + } + return timedOut; + } + } + + /** + * Cancels a request. + * + * @param requestId The request ID + * @param reason The reason for cancellation + * @return True if the request was cancelled, false if not found or already completed + */ + public boolean cancelRequest(String requestId, String reason) { + synchronized (lock) { + PendingRequest request = requestsById.get(requestId); + if (request == null) { + return false; + } + + boolean cancelled = request.cancel(reason); + if (cancelled) { + removeRequest(request); + } + return cancelled; + } + } + + /** + * Removes a request from the buffer. + * + * @param request The request to remove + */ + private void removeRequest(PendingRequest request) { + requestsById.remove(request.getRequestId()); + + List actionRequests = requestsByAction.get(request.getAction()); + if (actionRequests != null) { + actionRequests.remove(request); + if (actionRequests.isEmpty()) { + requestsByAction.remove(request.getAction()); + } + } + } + + /** + * Cleans up timed-out requests. + * + * @return The number of requests that were timed out + */ + public int cleanupTimedOutRequests() { + synchronized (lock) { + List timedOutRequests = new ArrayList<>(); + + for (PendingRequest request : requestsById.values()) { + if (request.isTimedOut()) { + request.timeout(); + timedOutRequests.add(request); + } + } + + // Remove timed-out requests + for (PendingRequest request : timedOutRequests) { + removeRequest(request); + } + + return timedOutRequests.size(); + } + } + + /** + * Gets the number of pending requests. + * + * @return The number of pending requests + */ + public int getPendingRequestCount() { + synchronized (lock) { + return requestsById.size(); + } + } + + /** + * Extracts the request ID from a JSON response. + * + * @param response The JSON response + * @return The request ID, or null if not found + */ + public static String extractRequestId(String response) { + try { + Pattern pattern = Pattern.compile("\"requestId\":\"([^\"]+)\""); + Matcher matcher = pattern.matcher(response); + + if (matcher.find()) { + return matcher.group(1); + } + } catch (Exception e) { + System.err.println("Error extracting requestId: " + e.getMessage()); + } + return null; + } + + /** + * Extracts the action from a JSON response. + * + * @param response The JSON response + * @return The action, or null if not found + */ + public static String extractAction(String response) { + try { + Pattern pattern = Pattern.compile("\"action\":\"([^\"]+)\""); + Matcher matcher = pattern.matcher(response); + + if (matcher.find()) { + return matcher.group(1); + } + } catch (Exception e) { + System.err.println("Error extracting action: " + e.getMessage()); + } + return null; + } + + /** + * Extracts the nonce from a JSON response. + * + * @param response The JSON response + * @return The nonce, or null if not found + */ + public static String extractNonce(String response) { + try { + Pattern pattern = Pattern.compile("\"nonce\":\"([^\"]+)\""); + Matcher matcher = pattern.matcher(response); + + if (matcher.find()) { + return matcher.group(1); + } + } catch (Exception e) { + System.err.println("Error extracting nonce: " + e.getMessage()); + } + return null; + } + + /** + * Handles an incoming response from KeePassXC. + * This method attempts to match the response to a pending request and complete it. + * + * @param response The JSON response from KeePassXC + * @return The number of requests that were completed + */ + public int handleResponse(String response) { + if (response == null || response.isEmpty()) { + return 0; + } + + synchronized (lock) { + String requestId = extractRequestId(response); + String action = extractAction(response); + + if (requestId != null) { + // Try to complete by request ID first + if (completeRequest(requestId, response)) { + return 1; + } + } + + if (action != null) { + // Then try to complete by action + return completeRequestsByAction(action, response); + } + + return 0; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/xpipe/app/prefs/PendingRequest.java b/app/src/main/java/io/xpipe/app/prefs/PendingRequest.java new file mode 100644 index 000000000..a14ef3beb --- /dev/null +++ b/app/src/main/java/io/xpipe/app/prefs/PendingRequest.java @@ -0,0 +1,152 @@ +package io.xpipe.app.prefs; + +import java.util.concurrent.CompletableFuture; + +/** + * Represents a pending request to KeePassXC. + * This class tracks the request details and provides methods to complete or cancel the request. + */ +public class PendingRequest { + private final String requestId; + private final String action; + private final CompletableFuture future; + private final long timestamp; + private final long timeout; + private boolean completed; + + /** + * Creates a new pending request. + * + * @param requestId The unique ID of the request + * @param action The action being performed (e.g., "associate", "get-logins") + * @param future The CompletableFuture that will be completed when the response is received + * @param timeout The timeout in milliseconds + */ + public PendingRequest(String requestId, String action, CompletableFuture future, long timeout) { + this.requestId = requestId; + this.action = action; + this.future = future; + this.timeout = timeout; + this.timestamp = System.currentTimeMillis(); + this.completed = false; + } + + /** + * Gets the request ID. + * + * @return The request ID + */ + public String getRequestId() { + return requestId; + } + + /** + * Gets the action. + * + * @return The action + */ + public String getAction() { + return action; + } + + /** + * Gets the completable future. + * + * @return The future + */ + public CompletableFuture getFuture() { + return future; + } + + /** + * Gets the timestamp when the request was created. + * + * @return The timestamp in milliseconds + */ + public long getTimestamp() { + return timestamp; + } + + /** + * Gets the timeout duration. + * + * @return The timeout in milliseconds + */ + public long getTimeout() { + return timeout; + } + + /** + * Checks if the request is completed. + * + * @return True if the request is completed, false otherwise + */ + public boolean isCompleted() { + return completed; + } + + /** + * Completes the request with the given response. + * + * @param response The response from KeePassXC + * @return True if the request was completed, false if it was already completed + */ + public boolean complete(String response) { + if (completed) { + return false; + } + + completed = true; + future.complete(response); + return true; + } + + /** + * Completes the request exceptionally with a timeout. + * + * @return True if the request was completed, false if it was already completed + */ + public boolean timeout() { + if (completed) { + return false; + } + + completed = true; + future.completeExceptionally(new TimeoutException("Request timed out after " + timeout + "ms")); + return true; + } + + /** + * Cancels the request. + * + * @param reason The reason for cancellation + * @return True if the request was cancelled, false if it was already completed + */ + public boolean cancel(String reason) { + if (completed) { + return false; + } + + completed = true; + future.completeExceptionally(new RuntimeException("Request cancelled: " + reason)); + return true; + } + + /** + * Checks if the request has timed out. + * + * @return True if the request has timed out, false otherwise + */ + public boolean isTimedOut() { + return !completed && System.currentTimeMillis() - timestamp > timeout; + } + + /** + * Exception class for request timeouts. + */ + public static class TimeoutException extends Exception { + public TimeoutException(String message) { + super(message); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/xpipe/app/prefs/TweetNaClHelper.java b/app/src/main/java/io/xpipe/app/prefs/TweetNaClHelper.java new file mode 100644 index 000000000..fa464478b --- /dev/null +++ b/app/src/main/java/io/xpipe/app/prefs/TweetNaClHelper.java @@ -0,0 +1,416 @@ +package io.xpipe.app.prefs; + +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.KeyGenerationParameters; +import org.bouncycastle.crypto.agreement.X25519Agreement; +import org.bouncycastle.crypto.generators.X25519KeyPairGenerator; +import org.bouncycastle.crypto.params.X25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.X25519PublicKeyParameters; +import org.bouncycastle.util.Arrays; + +import java.security.SecureRandom; +import java.util.Base64; + +/** + * Cryptographic helper for KeePassXC communication. + * + * This implementation properly mimics TweetNaCl.js behavior using BouncyCastle, + * implementing X25519 key exchange and XSalsa20-Poly1305 authenticated encryption + * which is what KeePassXC expects. + */ +public class TweetNaClHelper { + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + public static final int KEY_SIZE = 32; + public static final int NONCE_SIZE = 24; + + // Sigma constant ("expand 32-byte k") + private static final byte[] SIGMA = { + 101, 120, 112, 97, 110, 100, 32, 51, 50, 45, 98, 121, 116, 101, 32, 107 + }; + + public static class KeyPair { + private final byte[] publicKey; + private final byte[] secretKey; + + public KeyPair(byte[] publicKey, byte[] secretKey) { + this.publicKey = publicKey; + this.secretKey = secretKey; + } + + public byte[] getPublicKey() { + return publicKey; + } + + public byte[] getSecretKey() { + return secretKey; + } + } + + /** + * Generate a new key pair. + */ + public static KeyPair generateKeyPair() { + X25519KeyPairGenerator keyGen = new X25519KeyPairGenerator(); + keyGen.init(new KeyGenerationParameters(SECURE_RANDOM, 0)); + AsymmetricCipherKeyPair keyPair = keyGen.generateKeyPair(); + + X25519PrivateKeyParameters privateKey = (X25519PrivateKeyParameters) keyPair.getPrivate(); + X25519PublicKeyParameters publicKey = (X25519PublicKeyParameters) keyPair.getPublic(); + + return new KeyPair(publicKey.getEncoded(), privateKey.getEncoded()); + } + + /** + * Generate random bytes. + */ + public static byte[] randomBytes(int size) { + byte[] bytes = new byte[size]; + SECURE_RANDOM.nextBytes(bytes); + return bytes; + } + + /** + * Encrypt a message using NaCl box. + * + * This uses X25519 for key exchange and XSalsa20-Poly1305 for authenticated encryption. + * Follows the TweetNaCl.js implementation exactly. + */ + public static byte[] box(byte[] message, byte[] nonce, byte[] theirPublicKey, byte[] ourSecretKey) { + // Create a shared secret key for encryption - this is the 'beforenm' step + byte[] k = boxBeforeNm(theirPublicKey, ourSecretKey); + + // Now use this key with secretbox (the 'afternm' step) + return secretbox(message, nonce, k); + } + + /** + * Compute the shared key for box encryption (equivalent to nacl.box.before) + */ + private static byte[] boxBeforeNm(byte[] theirPublicKey, byte[] ourSecretKey) { + // First compute the X25519 shared secret + byte[] sharedSecret = computeSharedSecret(theirPublicKey, ourSecretKey); + + // Then use hsalsa20 to derive the key for XSalsa20 + byte[] k = new byte[32]; + hsalsa20(k, new byte[16], sharedSecret, SIGMA); + + return k; + } + + /** + * Decrypt a message using NaCl box open. + * Follows the TweetNaCl.js implementation exactly. + */ + public static byte[] boxOpen(byte[] encryptedMessage, byte[] nonce, byte[] theirPublicKey, byte[] ourSecretKey) { + // Create a shared secret key for decryption - this is the 'beforenm' step + byte[] k = boxBeforeNm(theirPublicKey, ourSecretKey); + + // Now use this key with secretbox_open (the 'afternm' step) + return secretboxOpen(encryptedMessage, nonce, k); + } + + /** + * Compute a shared secret using X25519. + */ + private static byte[] computeSharedSecret(byte[] publicKey, byte[] secretKey) { + try { + X25519PublicKeyParameters publicParams = new X25519PublicKeyParameters(publicKey, 0); + X25519PrivateKeyParameters privateParams = new X25519PrivateKeyParameters(secretKey, 0); + + X25519Agreement agreement = new X25519Agreement(); + agreement.init(privateParams); + + byte[] sharedSecret = new byte[agreement.getAgreementSize()]; + agreement.calculateAgreement(publicParams, sharedSecret, 0); + + return sharedSecret; + } catch (Exception e) { + throw new RuntimeException("Error computing shared secret: " + e.getMessage(), e); + } + } + + /** + * Proper implementation of HSalsa20 function from NaCl, used to derive the subkey. + * This matches the TweetNaCl.js implementation. + */ + private static void hsalsa20(byte[] out, byte[] nonce, byte[] key, byte[] constants) { + int[] x = new int[16]; // Working state + + // Load constants (sigma) + x[0] = load32(constants, 0); + x[5] = load32(constants, 4); + x[10] = load32(constants, 8); + x[15] = load32(constants, 12); + + // Load key + x[1] = load32(key, 0); + x[2] = load32(key, 4); + x[3] = load32(key, 8); + x[4] = load32(key, 12); + x[11] = load32(key, 16); + x[12] = load32(key, 20); + x[13] = load32(key, 24); + x[14] = load32(key, 28); + + // Load nonce + x[6] = load32(nonce, 0); + x[7] = load32(nonce, 4); + x[8] = load32(nonce, 8); + x[9] = load32(nonce, 12); + + // Perform 20 rounds of the Salsa20 core + for (int i = 0; i < 20; i += 2) { + // Column round + x[4] ^= rotl32(x[0] + x[12], 7); + x[8] ^= rotl32(x[4] + x[0], 9); + x[12] ^= rotl32(x[8] + x[4], 13); + x[0] ^= rotl32(x[12] + x[8], 18); + + x[9] ^= rotl32(x[5] + x[1], 7); + x[13] ^= rotl32(x[9] + x[5], 9); + x[1] ^= rotl32(x[13] + x[9], 13); + x[5] ^= rotl32(x[1] + x[13], 18); + + x[14] ^= rotl32(x[10] + x[6], 7); + x[2] ^= rotl32(x[14] + x[10], 9); + x[6] ^= rotl32(x[2] + x[14], 13); + x[10] ^= rotl32(x[6] + x[2], 18); + + x[3] ^= rotl32(x[15] + x[11], 7); + x[7] ^= rotl32(x[3] + x[15], 9); + x[11] ^= rotl32(x[7] + x[3], 13); + x[15] ^= rotl32(x[11] + x[7], 18); + + // Diagonal round + x[1] ^= rotl32(x[0] + x[3], 7); + x[2] ^= rotl32(x[1] + x[0], 9); + x[3] ^= rotl32(x[2] + x[1], 13); + x[0] ^= rotl32(x[3] + x[2], 18); + + x[6] ^= rotl32(x[5] + x[4], 7); + x[7] ^= rotl32(x[6] + x[5], 9); + x[4] ^= rotl32(x[7] + x[6], 13); + x[5] ^= rotl32(x[4] + x[7], 18); + + x[11] ^= rotl32(x[10] + x[9], 7); + x[8] ^= rotl32(x[11] + x[10], 9); + x[9] ^= rotl32(x[8] + x[11], 13); + x[10] ^= rotl32(x[9] + x[8], 18); + + x[12] ^= rotl32(x[15] + x[14], 7); + x[13] ^= rotl32(x[12] + x[15], 9); + x[14] ^= rotl32(x[13] + x[12], 13); + x[15] ^= rotl32(x[14] + x[13], 18); + } + + // Extract the output + store32(out, 0, x[0]); + store32(out, 4, x[5]); + store32(out, 8, x[10]); + store32(out, 12, x[15]); + store32(out, 16, x[6]); + store32(out, 20, x[7]); + store32(out, 24, x[8]); + store32(out, 28, x[9]); + } + + /** + * Implementation of secretbox from NaCl. + */ + private static byte[] secretbox(byte[] message, byte[] nonce, byte[] key) { + // For compatibility with TweetNaCl, we implement the same logic + // Our secretbox will combine XSalsa20 encryption with Poly1305 MAC + + try { + // In TweetNaCl.js, secretbox adds 32 zero bytes before the message + byte[] paddedMessage = new byte[32 + message.length]; + System.arraycopy(message, 0, paddedMessage, 32, message.length); + + // Apply XSalsa20 encryption + byte[] c = new byte[paddedMessage.length]; + streamXorXSalsa20(c, paddedMessage, paddedMessage.length, nonce, key); + + // The first 16 bytes are used for the Poly1305 tag (MAC) + byte[] tag = new byte[16]; + crypto_onetimeauth(tag, c, 32, c.length - 32, Arrays.copyOf(c, 32)); + + // Copy tag into the first 16 bytes of c + System.arraycopy(tag, 0, c, 16, 16); + + // Clear the first 16 bytes (not used in the result) + for (int i = 0; i < 16; i++) { + c[i] = 0; + } + + // Return result skipping the first 16 bytes (boxzerobytes) + return Arrays.copyOfRange(c, 16, c.length); + } catch (Exception e) { + throw new RuntimeException("Encryption failed: " + e.getMessage(), e); + } + } + + /** + * Implementation of secretbox_open from NaCl. + */ + private static byte[] secretboxOpen(byte[] encryptedMessage, byte[] nonce, byte[] key) { + // Check if the message is long enough + if (encryptedMessage.length < 16) { + return null; // Not enough data + } + + try { + // Reconstruct the ciphertext with boxzerobytes prefix + byte[] c = new byte[16 + encryptedMessage.length]; + System.arraycopy(encryptedMessage, 0, c, 16, encryptedMessage.length); + + // Verify the Poly1305 authentication tag + byte[] subkey = Arrays.copyOf(new byte[32], 32); // First 32 bytes of the keystream + streamXSalsa20(subkey, 32, nonce, key); + + if (crypto_onetimeauth_verify(c, 16, c, 32, c.length - 32, subkey) != 0) { + return null; // MAC verification failed + } + + // Decrypt the message + byte[] m = new byte[c.length]; + streamXorXSalsa20(m, c, c.length, nonce, key); + + // Return the actual message (skipping the 32 zero bytes prefix) + return Arrays.copyOfRange(m, 32, m.length); + } catch (Exception e) { + return null; // Return null on decryption failure (as in NaCl) + } + } + + /** + * Core XSalsa20 stream cipher function. + */ + private static void streamXSalsa20(byte[] out, int outLength, byte[] nonce, byte[] key) { + // First, derive a subkey using HSalsa20 + byte[] subkey = new byte[32]; + hsalsa20(subkey, Arrays.copyOf(nonce, 16), key, SIGMA); + + // Then use the subkey with the remaining bytes of the nonce + streamSalsa20(out, outLength, Arrays.copyOfRange(nonce, 16, 24), subkey); + } + + /** + * XSalsa20 stream XOR function + */ + private static void streamXorXSalsa20(byte[] c, byte[] m, int mlen, byte[] nonce, byte[] key) { + // First, derive a subkey using HSalsa20 + byte[] subkey = new byte[32]; + hsalsa20(subkey, Arrays.copyOf(nonce, 16), key, SIGMA); + + // Then use the subkey with the remaining bytes of the nonce + streamXorSalsa20(c, m, mlen, Arrays.copyOfRange(nonce, 16, 24), subkey); + } + + /** + * Core Salsa20 stream cipher function. + */ + private static void streamSalsa20(byte[] out, int outLength, byte[] nonce, byte[] key) { + byte[] zeros = new byte[outLength]; + streamXorSalsa20(out, zeros, outLength, nonce, key); + } + + /** + * Salsa20 stream XOR function + */ + private static void streamXorSalsa20(byte[] c, byte[] m, int mlen, byte[] nonce, byte[] key) { + // Use BouncyCastle's Salsa20 implementation + org.bouncycastle.crypto.engines.Salsa20Engine salsa20 = new org.bouncycastle.crypto.engines.Salsa20Engine(); + org.bouncycastle.crypto.params.ParametersWithIV params = + new org.bouncycastle.crypto.params.ParametersWithIV( + new org.bouncycastle.crypto.params.KeyParameter(key), nonce); + salsa20.init(true, params); + + salsa20.processBytes(m, 0, mlen, c, 0); + } + + /** + * Poly1305 one-time authentication. + */ + private static void crypto_onetimeauth(byte[] out, byte[] m, int mpos, int mlen, byte[] key) { + org.bouncycastle.crypto.macs.Poly1305 poly1305 = new org.bouncycastle.crypto.macs.Poly1305(); + poly1305.init(new org.bouncycastle.crypto.params.KeyParameter(key)); + + poly1305.update(m, mpos, mlen); + + poly1305.doFinal(out, 0); + } + + /** + * Verify a Poly1305 one-time authentication tag. + */ + private static int crypto_onetimeauth_verify(byte[] h, int hpos, byte[] m, int mpos, int mlen, byte[] key) { + byte[] correct = new byte[16]; + crypto_onetimeauth(correct, m, mpos, mlen, key); + return crypto_verify_16(h, hpos, correct, 0); + } + + /** + * Verify 16 bytes in constant time. + */ + private static int crypto_verify_16(byte[] x, int xpos, byte[] y, int ypos) { + return constantTimeEquals(Arrays.copyOfRange(x, xpos, xpos + 16), + Arrays.copyOfRange(y, ypos, ypos + 16)) ? 0 : -1; + } + + /** + * Helper for loading 32-bit integers (little-endian). + */ + private static int load32(byte[] src, int offset) { + int u = src[offset] & 0xff; + u |= (src[offset + 1] & 0xff) << 8; + u |= (src[offset + 2] & 0xff) << 16; + u |= (src[offset + 3] & 0xff) << 24; + return u; + } + + /** + * Helper for storing 32-bit integers (little-endian). + */ + private static void store32(byte[] dst, int offset, int u) { + dst[offset] = (byte) (u & 0xff); + dst[offset + 1] = (byte) ((u >>> 8) & 0xff); + dst[offset + 2] = (byte) ((u >>> 16) & 0xff); + dst[offset + 3] = (byte) ((u >>> 24) & 0xff); + } + + /** + * Rotate a 32-bit integer left by the specified number of bits. + */ + private static int rotl32(int x, int b) { + return ((x << b) | (x >>> (32 - b))); + } + + /** + * Compare two byte arrays in constant time to prevent timing attacks. + */ + private static boolean constantTimeEquals(byte[] a, byte[] b) { + if (a.length != b.length) { + return false; + } + + int result = 0; + for (int i = 0; i < a.length; i++) { + result |= a[i] ^ b[i]; + } + return result == 0; + } + + /** + * Encode bytes as Base64. + */ + public static String encodeBase64(byte[] data) { + return Base64.getEncoder().encodeToString(data); + } + + /** + * Decode Base64 to bytes. + */ + public static byte[] decodeBase64(String data) { + return Base64.getDecoder().decode(data); + } +} \ No newline at end of file diff --git a/core/src/main/java/io/xpipe/core/process/ShellView.java b/core/src/main/java/io/xpipe/core/process/ShellView.java index 22b591df2..e77094957 100644 --- a/core/src/main/java/io/xpipe/core/process/ShellView.java +++ b/core/src/main/java/io/xpipe/core/process/ShellView.java @@ -76,11 +76,11 @@ public class ShellView { return isRoot; } - public Optional findProgram(String name) throws Exception { + public Optional findProgram(String name) throws Exception { var out = shellControl .command(shellControl.getShellDialect().getWhichCommand(name)) .readStdoutIfPossible(); - return out.flatMap(s -> s.lines().findFirst()).map(String::trim); + return out.flatMap(s -> s.lines().findFirst()).map(String::trim).map(s -> FilePath.of(s)); } public boolean isInPath(String executable) throws Exception {