mirror of
https://github.com/xpipe-io/xpipe.git
synced 2026-05-03 19:30:31 +00:00
Some keepass work
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Path> 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> messageData = new HashMap<>();
|
||||
messageData.put("action", "get-logins");
|
||||
messageData.put("url", url);
|
||||
|
||||
// Add the keys
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> map) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("{");
|
||||
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, Object> 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<String, Object> jsonToMap(String json) {
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, PendingRequest> requestsById;
|
||||
private final Map<String, List<PendingRequest>> 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<PendingRequest> getRequestsByAction(String action) {
|
||||
synchronized (lock) {
|
||||
List<PendingRequest> 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<PendingRequest> requests = requestsByAction.get(action);
|
||||
if (requests == null || requests.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
List<PendingRequest> 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<PendingRequest> 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<PendingRequest> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -76,11 +76,11 @@ public class ShellView {
|
||||
return isRoot;
|
||||
}
|
||||
|
||||
public Optional<String> findProgram(String name) throws Exception {
|
||||
public Optional<FilePath> 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 {
|
||||
|
||||
Reference in New Issue
Block a user