KeePass fixes

This commit is contained in:
crschnick
2025-03-21 20:06:05 +00:00
parent d846b1101b
commit 07a58fb4d3
4 changed files with 163 additions and 917 deletions

View File

@@ -19,8 +19,9 @@ public class KeePassClient {
@SneakyThrows
public static String receive(String key) {
var client = getOrCreate();
client.getDatabaseGroups();
return client.getLogins("abc");
var response = client.getLoginsMessage("https://redirect.pizza");
var password = client.getPassword(response);
return password;
}
public static void reset() {

View File

@@ -1,12 +1,15 @@
package io.xpipe.app.prefs;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.type.TypeFactory;
import io.xpipe.app.issue.ErrorEvent;
import io.xpipe.app.util.ThreadHelper;
import io.xpipe.core.util.JacksonMapper;
import lombok.Getter;
import lombok.SneakyThrows;
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;
@@ -15,10 +18,6 @@ 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;
@@ -43,20 +42,33 @@ public class KeePassNativeClient {
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;}
/**
* Extracts the action from a JSON response.
*
* @param response The JSON response
* @return The action, or null if not found
*/
private 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;
}
public void useExistingAssociationKey(KeePassAssociationKey key) {
this.associationKey = key;
}
@@ -77,12 +89,7 @@ public class KeePassNativeClient {
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;
}
@@ -190,12 +197,14 @@ public class KeePassNativeClient {
// 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("error")) {
throw ErrorEvent.expected(new IllegalStateException(responseMap.get("error").toString()));
}
if (responseMap.containsKey("message") && responseMap.containsKey("nonce")) {
String encryptedResponse = (String) responseMap.get("message");
String responseNonce = (String) responseMap.get("nonce");
@@ -205,10 +214,7 @@ public class KeePassNativeClient {
Map<String, Object> parsedResponse = jsonToMap(decryptedResponse);
boolean success = parsedResponse.containsKey("success") &&
"true".equals(parsedResponse.get("success").toString());
if (success) {
associated = true;
} else {
if (!success) {
throw new IllegalStateException("KeePassXC association failed");
}
}
@@ -222,7 +228,7 @@ public class KeePassNativeClient {
* @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 {
public String getLoginsMessage(String url) throws IOException {
// Generate a nonce
String nonce = TweetNaClHelper.encodeBase64(TweetNaClHelper.randomBytes(TweetNaClHelper.NONCE_SIZE));
@@ -240,9 +246,6 @@ public class KeePassNativeClient {
// Encrypt the message
String encryptedMessage = encrypt(messageData, nonce);
if (encryptedMessage == null) {
return null;
}
// Build the request
Map<String, Object> request = new HashMap<>();
@@ -252,39 +255,44 @@ public class KeePassNativeClient {
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;
Map<String, Object> responseMap = jsonToMap(responseJson);
if (responseMap.containsKey("error")) {
throw ErrorEvent.expected(new IllegalStateException(responseMap.get("error").toString()));
}
if (responseMap.containsKey("message") && responseMap.containsKey("nonce")) {
String encryptedResponse = (String) responseMap.get("message");
String responseNonce = (String) responseMap.get("nonce");
return decrypt(encryptedResponse, responseNonce);
}
// 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());
throw new IllegalStateException("Login query failed for an unknown reason");
}
public String getPassword(String message) throws IOException {
var tree = JacksonMapper.getDefault().readTree(message);
var count = tree.required("count").asInt();
if (count == 0) {
throw ErrorEvent.expected(new IllegalArgumentException("No password was found for specified key"));
}
return null;
if (count > 1) {
throw ErrorEvent.expected(new IllegalArgumentException("Password key is ambiguous and returned multiple results"));
}
var object = (ObjectNode) tree.required("entries").get(0);
var password = object.required("password").asText();
return password;
}
/**
* Disconnects from KeePassXC.
*/
public void disconnect() {
if (responseHandler != null) {
responseHandler.interrupt();
responseHandler = null;
}
process.destroy();
process = null;
}
@@ -299,43 +307,40 @@ public class KeePassNativeClient {
* @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();
long startTime = System.currentTimeMillis();
while (System.currentTimeMillis() - startTime < timeout) {
var response = receiveNativeMessage();
if (filterResponse(action, response)) {
continue;
}
return response;
}
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();
throw new IllegalStateException("KeePassXC " + action + " request timed out");
}
private boolean filterResponse(String action, String response) {
System.out.println("Received response: " + response);
// Extract action
String extractedAction = extractAction(response);
// Special handling for action-specific responses
if ("database-locked".equals(extractedAction) || "database-unlocked".equals(extractedAction)) {
System.out.println("Database state changed: " + extractedAction);
// Update state based on the action
if ("database-locked".equals(extractedAction)) {
return true;
}
}
if (action.equals(extractedAction)) {
return false;
} else {
return true;
}
}
@@ -346,99 +351,19 @@ public class KeePassNativeClient {
* @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();
Pattern pattern = Pattern.compile("\"requestId\":\"([^\"]+)\"");
Matcher matcher = pattern.matcher(message);
if (matcher.find()) {
return matcher.group(1);
}
} catch (Exception e) {
System.err.println("Error in response handler: " + e.getMessage());
System.err.println("Error extracting requestId: " + e.getMessage());
}
return null;
}
/**
* 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).
@@ -490,70 +415,6 @@ public class KeePassNativeClient {
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.
*
@@ -562,33 +423,18 @@ public class KeePassNativeClient {
* @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;
}
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()
);
return TweetNaClHelper.encodeBase64(encrypted);
}
/**
@@ -599,32 +445,21 @@ public class KeePassNativeClient {
* @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;
byte[] messageBytes = TweetNaClHelper.decodeBase64(encryptedMessage);
byte[] nonceBytes = TweetNaClHelper.decodeBase64(nonce);
byte[] decrypted = TweetNaClHelper.boxOpen(
messageBytes,
nonceBytes,
serverPublicKey,
keyPair.getSecretKey()
);
if (decrypted == null) {
throw new IllegalArgumentException("Message decryption failed");
}
return new String(decrypted, StandardCharsets.UTF_8);
}
/**
@@ -633,7 +468,7 @@ public class KeePassNativeClient {
* @return True if successful, false otherwise
* @throws IOException If there's an error communicating with KeePassXC
*/
public boolean associate() throws IOException {
public void associate() throws IOException {
// Generate a key pair for identification
TweetNaClHelper.KeyPair idKeyPair = TweetNaClHelper.generateKeyPair();
@@ -648,10 +483,7 @@ public class KeePassNativeClient {
// 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");
@@ -660,177 +492,54 @@ public class KeePassNativeClient {
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());
Map<String, Object> responseMap = jsonToMap(responseJson);
if (responseMap.containsKey("error")) {
throw ErrorEvent.expected(new IllegalStateException(responseMap.get("error").toString()));
}
return false;
if (responseMap.containsKey("message") && responseMap.containsKey("nonce")) {
String encryptedResponse = (String) responseMap.get("message");
String responseNonce = (String) responseMap.get("nonce");
String decryptedResponse = decrypt(encryptedResponse, responseNonce);
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);
return;
}
}
throw new IllegalStateException("KeePassXC association failed");
}
/**
* Convert a map to a JSON string.
*/
@SneakyThrows
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();
var mapper = JacksonMapper.getDefault();
return mapper.writeValueAsString(map);
}
/**
* Convert a JSON string to a map.
*/
@SneakyThrows
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());
}
var mapper = JacksonMapper.getDefault();
var type = TypeFactory.defaultInstance().constructType(new TypeReference<>() {});
Map<String, Object> map = mapper.readValue(json, type);
return map;
}
}

View File

@@ -1,312 +0,0 @@
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;
}
}
}

View File

@@ -1,152 +0,0 @@
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);
}
}
}