Merge branch keepassxc-multi

This commit is contained in:
crschnick
2025-12-16 08:13:13 +00:00
parent e8b5b85e5c
commit b5fdbc0f4c
6 changed files with 194 additions and 74 deletions
@@ -12,6 +12,8 @@ import io.xpipe.core.InPlaceSecretValue;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.geometry.Orientation;
import javafx.scene.control.ComboBox;
import javafx.scene.layout.Region;
@@ -372,6 +374,16 @@ public class OptionsBuilder {
return this;
}
public <T> OptionsBuilder addProperty(ObservableList<T> prop) {
// For updating the options builder binding on list change, it doesn't support observable lists
var listHashProp = new SimpleIntegerProperty(0);
prop.addListener((ListChangeListener<T>) c -> {
listHashProp.set(c.getList().hashCode());
});
addProperty(listHashProp);
return this;
}
public OptionsBuilder addSecret(Property<InPlaceSecretValue> prop, boolean copy) {
var comp = new SecretFieldComp(prop, copy);
pushComp(comp);
@@ -0,0 +1,39 @@
package io.xpipe.app.pwman;
import atlantafx.base.theme.Styles;
import io.xpipe.app.comp.SimpleComp;
import io.xpipe.app.comp.base.IconButtonComp;
import io.xpipe.app.platform.OptionsBuilder;
import javafx.geometry.Insets;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
public class KeePassXcAssociationComp extends SimpleComp {
private final KeePassXcAssociationKey associationKey;
private final Runnable onRemove;
public KeePassXcAssociationComp(KeePassXcAssociationKey associationKey, Runnable onRemove) {this.associationKey = associationKey;
this.onRemove = onRemove;
}
@Override
protected Region createSimple() {
var key = associationKey.getKey().getSecretValue();
var censoredKey = key.substring(0, 6) + "*".repeat(key.length() - 6);
var nameLabel = new Label(associationKey.getId());
nameLabel.getStyleClass().add(Styles.TEXT_BOLD);
nameLabel.setPrefWidth(150);
var keyLabel = new Label(censoredKey);
keyLabel.setMaxWidth(2000);
HBox.setHgrow(keyLabel, Priority.ALWAYS);
var delButton = new IconButtonComp("mdi2t-trash-can-outline", onRemove).createRegion();
var box = new HBox(nameLabel, keyLabel, delButton);
box.setSpacing(8);
box.setPadding(new Insets(5, 0, 5, 0));
return box;
}
}
@@ -1,8 +1,10 @@
package io.xpipe.app.pwman;
import io.xpipe.app.comp.base.ButtonComp;
import io.xpipe.app.comp.base.ListBoxViewComp;
import io.xpipe.app.core.AppI18n;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.platform.DerivedObservableList;
import io.xpipe.app.platform.OptionsBuilder;
import io.xpipe.app.prefs.AppPrefs;
import io.xpipe.app.process.LocalShell;
@@ -10,10 +12,11 @@ import io.xpipe.app.util.*;
import io.xpipe.core.OsType;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleObjectProperty;
import com.fasterxml.jackson.annotation.JsonTypeName;
import javafx.collections.FXCollections;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
@@ -22,6 +25,7 @@ import lombok.extern.jackson.Jacksonized;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;
@@ -29,47 +33,51 @@ import java.util.regex.Pattern;
@Getter
@Builder(toBuilder = true)
@ToString
@Jacksonized
@JsonTypeName("keePassXc")
public class KeePassXcPasswordManager implements PasswordManager {
private static KeePassXcProxyClient client;
private final KeePassXcAssociationKey associationKey;
private final List<KeePassXcAssociationKey> associationKeys;
@SuppressWarnings("unused")
public static OptionsBuilder createOptions(Property<KeePassXcPasswordManager> p) {
var prop = new SimpleObjectProperty<KeePassXcAssociationKey>();
var prop = FXCollections.<KeePassXcAssociationKey>observableArrayList();
p.subscribe(keePassXcManager -> {
prop.set(keePassXcManager != null ? keePassXcManager.getAssociationKey() : null);
DerivedObservableList.wrap(prop, true).setContent(keePassXcManager != null && keePassXcManager.getAssociationKeys() != null ? keePassXcManager.getAssociationKeys() : List.of());
});
var associationsListComp = new ListBoxViewComp<>(prop, prop, k -> new KeePassXcAssociationComp(k, () -> prop.remove(k)), false);
return new OptionsBuilder()
.nameAndDescription("keePassXcNotAssociated")
.addComp(new ButtonComp(AppI18n.observable("keePassXcNotAssociatedButton"), () -> {
ThreadHelper.runFailableAsync(() -> {
var r = associate();
Platform.runLater(() -> {
prop.setValue(r);
prop.add(r);
});
});
}))
.hide(prop.isNotNull())
.hide(Bindings.isNotEmpty(prop))
.nameAndDescription("keePassXcAssociated")
.addComp(new OptionsBuilder()
.name("identifier")
.addStaticString(prop.map(k -> k.getId()))
.name("key")
.addStaticString(prop.map(k -> {
var s = k.getKey().getSecretValue();
return s.substring(0, 6) + "*".repeat(s.length() - 6);
}))
.buildComp()
.maxWidth(600))
.hide(prop.isNull())
.addComp(associationsListComp)
.hide(Bindings.isEmpty(prop))
.nameAndDescription("keePassXcAssociateMore")
.addComp(new ButtonComp(AppI18n.observable("keePassXcNotAssociatedButton"), () -> {
ThreadHelper.runFailableAsync(() -> {
var r = associate();
Platform.runLater(() -> {
prop.add(r);
});
});
}))
.hide(Bindings.isEmpty(prop))
.addProperty(prop)
.bind(
() -> {
return new KeePassXcPasswordManager(prop.getValue());
return new KeePassXcPasswordManager(prop);
},
p);
}
@@ -84,23 +92,14 @@ public class KeePassXcPasswordManager implements PasswordManager {
try {
c.connect();
c.exchangeKeys();
c.associate();
c.testAssociation();
return c.getAssociationKey();
var key = c.associate();
c.testAssociation(key);
return key;
} finally {
c.disconnect();
}
}
private static CredentialResult receive(String key) throws Exception {
var hasScheme = Pattern.compile("^\\w+://").matcher(key).find();
var fixedKey = hasScheme ? key : "https://" + key;
var client = getOrCreate();
var response = client.getLoginsMessage(fixedKey);
var credentials = client.getCredentials(response);
return credentials;
}
public static void reset() {
if (client != null) {
client.disconnect();
@@ -120,31 +119,46 @@ public class KeePassXcPasswordManager implements PasswordManager {
c.connect();
c.exchangeKeys();
var pref = AppPrefs.get().passwordManager();
KeePassXcAssociationKey cached =
pref.getValue() instanceof KeePassXcPasswordManager kpm ? kpm.getAssociationKey() : null;
if (cached != null) {
c.useExistingAssociationKey(cached);
try {
c.testAssociation();
} catch (Exception e) {
ErrorEventFactory.fromThrowable(e).handle();
c.useExistingAssociationKey(null);
cached = null;
}
var available = pref.getValue() instanceof KeePassXcPasswordManager kpm ? kpm.getAssociationKeys() : null;
if (available == null) {
available = new ArrayList<>();
}
if (cached == null) {
c.associate();
c.testAssociation();
if (pref.getValue() instanceof KeePassXcPasswordManager kpm
&& !c.getAssociationKey().equals(kpm.getAssociationKey())) {
if (!available.isEmpty()) {
var valid = false;
Exception first = null;
for (KeePassXcAssociationKey key : new ArrayList<>(available)) {
try {
c.testAssociation(key);
valid = true;
} catch (Exception e) {
if (first == null) {
first = e;
}
}
}
// Only one association needs to work
if (!valid) {
ErrorEventFactory.preconfigure(ErrorEventFactory.fromThrowable(first)
.description("KeePassXC association for " + available.getFirst().getKey() + " failed")
.expected());
throw first;
}
} else {
var key = c.associate();
c.testAssociation(key);
if (pref.getValue() instanceof KeePassXcPasswordManager kpm) {
AppPrefs.get()
.setFromExternal(
AppPrefs.get().passwordManager(),
kpm.toBuilder()
.associationKey(c.getAssociationKey())
.associationKeys(List.of(key))
.build());
}
}
client = c;
}
@@ -212,7 +226,11 @@ public class KeePassXcPasswordManager implements PasswordManager {
@Override
public CredentialResult retrieveCredentials(String key) {
try {
return KeePassXcPasswordManager.receive(key);
var hasScheme = Pattern.compile("^\\w+://").matcher(key).find();
var fixedKey = hasScheme ? key : "https://" + key;
var client = getOrCreate();
var credentials = client.getCredentials(associationKeys, fixedKey);
return credentials;
} catch (Exception e) {
ErrorEventFactory.fromThrowable(e).handle();
return null;
@@ -44,9 +44,6 @@ public class KeePassXcProxyClient {
private TweetNaClHelper.KeyPair keyPair;
private byte[] serverPublicKey;
@Getter
private KeePassXcAssociationKey associationKey;
public KeePassXcProxyClient(Path proxyExecutable) {
this.proxyExecutable = proxyExecutable;
}
@@ -67,10 +64,6 @@ public class KeePassXcProxyClient {
return null;
}
public void useExistingAssociationKey(KeePassXcAssociationKey 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.
@@ -173,7 +166,7 @@ public class KeePassXcProxyClient {
*
* @throws IOException If there's an error communicating with KeePassXC
*/
public void testAssociation() throws IOException {
public void testAssociation(KeePassXcAssociationKey associationKey) throws IOException {
if (associationKey == null) {
// We need to do an association first
throw ErrorEventFactory.expected(
@@ -236,7 +229,7 @@ public class KeePassXcProxyClient {
* @return The response JSON, or null if failed
* @throws IOException If there's an error communicating with KeePassXC
*/
public String getLoginsMessage(String url) throws IOException {
private String getLoginsMessage(List<KeePassXcAssociationKey> associationKeys, String url) throws IOException {
// Generate a nonce
String nonce = TweetNaClHelper.encodeBase64(TweetNaClHelper.randomBytes(TweetNaClHelper.NONCE_SIZE));
@@ -246,11 +239,14 @@ public class KeePassXcProxyClient {
messageData.put("url", url);
// Add the keys
Map<String, Object> keyData = new HashMap<>();
keyData.put("id", associationKey.getId());
keyData.put("key", associationKey.getKey().getSecretValue());
messageData.put("keys", new Map[] {keyData});
var keyArray = new Object[associationKeys.size()];
for (int i = 0; i < associationKeys.size(); i++) {
Map<String, Object> keyData = new HashMap<>();
keyData.put("id", associationKeys.get(i).getId());
keyData.put("key", associationKeys.get(i).getKey().getSecretValue());
keyArray[i] = keyData;
}
messageData.put("keys", keyArray);
// Encrypt the message
String encryptedMessage = encrypt(messageData, nonce);
@@ -282,7 +278,8 @@ public class KeePassXcProxyClient {
throw new IllegalStateException("Login query failed for an unknown reason");
}
public PasswordManager.CredentialResult getCredentials(String message) throws IOException {
public PasswordManager.CredentialResult getCredentials(List<KeePassXcAssociationKey> associationKeys, String key) throws IOException {
var message = getLoginsMessage(associationKeys, key);
var tree = JacksonMapper.getDefault().readTree(message);
var count = tree.required("count").asInt();
if (count == 0) {
@@ -449,7 +446,7 @@ public class KeePassXcProxyClient {
*
* @throws IOException If there's an error communicating with KeePassXC
*/
public void associate() throws IOException {
public KeePassXcAssociationKey associate() throws IOException {
// Generate a key pair for identification
TweetNaClHelper.KeyPair idKeyPair = TweetNaClHelper.generateKeyPair();
@@ -495,10 +492,7 @@ public class KeePassXcProxyClient {
if (success && parsedResponse.containsKey("id") && parsedResponse.containsKey("hash")) {
String id = (String) parsedResponse.get("id");
var key = InPlaceSecretValue.of(TweetNaClHelper.encodeBase64(idKeyPair.getPublicKey()));
associationKey = new KeePassXcAssociationKey(id, key);
return;
return new KeePassXcAssociationKey(id, key);
}
}
@@ -4,6 +4,8 @@ import io.xpipe.app.ext.HostAddress;
import io.xpipe.app.process.ShellDialect;
import io.xpipe.app.process.ShellDialects;
import io.xpipe.app.process.ShellScript;
import io.xpipe.app.pwman.KeePassXcAssociationKey;
import io.xpipe.app.pwman.KeePassXcPasswordManager;
import io.xpipe.app.pwman.PasswordManager;
import io.xpipe.app.rdp.ExternalRdpClient;
import io.xpipe.app.secret.EncryptedValue;
@@ -35,6 +37,7 @@ import com.fasterxml.jackson.databind.type.SimpleType;
import java.io.CharArrayReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;
@@ -71,6 +74,9 @@ public class AppJacksonModule extends SimpleModule {
addSerializer(HostAddress.class, new HostAddressSerializer());
addDeserializer(HostAddress.class, new HostAddressDeserializer());
addSerializer(KeePassXcPasswordManager.class, new KeePassXcPasswordManagerSerializer());
addDeserializer(KeePassXcPasswordManager.class, new KeePassXcPasswordManagerDeserializer());
for (ShellDialect t : ShellDialects.ALL) {
context.registerSubtypes(new NamedType(t.getClass()));
}
@@ -81,8 +87,54 @@ public class AppJacksonModule extends SimpleModule {
context.registerSubtypes(ExternalVncClient.getClasses());
context.registerSubtypes(ExternalRdpClient.getClasses());
context.addSerializers(_serializers);
context.addDeserializers(_deserializers);
super.setupModule(context);
}
public static class KeePassXcPasswordManagerSerializer extends JsonSerializer<KeePassXcPasswordManager> {
@Override
public void serialize(KeePassXcPasswordManager value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
if (value == null) {
jgen.writeNull();
return;
}
var tree = JacksonMapper.getDefault().valueToTree(value.getAssociationKeys());
var object = JsonNodeFactory.instance.objectNode();
object.put("type", "keePassXc");
object.set("associationKeys", tree);
jgen.writeTree(object);
}
@Override
public void serializeWithType(
KeePassXcPasswordManager value, JsonGenerator gen, SerializerProvider serializers,
TypeSerializer typeSer
) throws IOException {
serialize(value, gen, serializers);
}
}
public static class KeePassXcPasswordManagerDeserializer extends JsonDeserializer<KeePassXcPasswordManager> {
@Override
@SuppressWarnings("unchecked")
public KeePassXcPasswordManager deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonNode tree = JacksonMapper.getDefault().readTree(p);
if (tree == null || !tree.isObject()) {
return null;
}
if (tree.has("associationKey")) {
var parsed = JacksonMapper.getDefault().treeToValue(tree.required("associationKey"), KeePassXcAssociationKey.class);
return KeePassXcPasswordManager.builder().associationKeys(parsed != null ? List.of(parsed) : List.of()).build();
} else {
var javaType =
JacksonMapper.getDefault().getTypeFactory().constructCollectionLikeType(List.class, KeePassXcAssociationKey.class);
var parsed = (List<KeePassXcAssociationKey>) JacksonMapper.getDefault().treeToValue(tree.required("associationKeys"), javaType);
return KeePassXcPasswordManager.builder().associationKeys(parsed).build();
}
}
}
public static class OsTypeSerializer extends JsonSerializer<OsType> {
+8 -3
View File
@@ -1392,10 +1392,15 @@ documentation=Documentation
report=Report error
keePassXcNotAssociated=KeePassXC link
keePassXcNotAssociatedDescription=XPipe is not associated with your local KeePassXC database. Click below to perform the one-time step of associating XPipe with the KeePassXC database so that XPipe can query passwords.
keePassXcAssociated=KeePassXC link
keePassXcAssociatedDescription=XPipe is connected to the local KeePassXC database.
keePassXcAssociateMore=Connect more databases
keePassXcAssociateMoreDescription=You can be connected to multiple KeePassXC databases at the same time
#force
keePassXcAssociated=KeePassXC links
#force
keePassXcAssociatedDescription=XPipe is connected to the following local KeePassXC databases:
#context: verb, link together
keePassXcNotAssociatedButton=Link
#force
keePassXcNotAssociatedButton=Link database
identifier=Identifier
passwordManagerCommand=Custom command
passwordManagerCommandDescription=The custom command to execute to fetch passwords. The placeholder string $KEY will be replaced by the quoted password key when called. This should call your password manager CLI to print the password to stdout, e.g. mypassmgr get $KEY.