diff --git a/app/src/main/java/io/xpipe/app/platform/OptionsBuilder.java b/app/src/main/java/io/xpipe/app/platform/OptionsBuilder.java index 9a7ecacaa..38e007f62 100644 --- a/app/src/main/java/io/xpipe/app/platform/OptionsBuilder.java +++ b/app/src/main/java/io/xpipe/app/platform/OptionsBuilder.java @@ -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 OptionsBuilder addProperty(ObservableList prop) { + // For updating the options builder binding on list change, it doesn't support observable lists + var listHashProp = new SimpleIntegerProperty(0); + prop.addListener((ListChangeListener) c -> { + listHashProp.set(c.getList().hashCode()); + }); + addProperty(listHashProp); + return this; + } + public OptionsBuilder addSecret(Property prop, boolean copy) { var comp = new SecretFieldComp(prop, copy); pushComp(comp); diff --git a/app/src/main/java/io/xpipe/app/pwman/KeePassXcAssociationComp.java b/app/src/main/java/io/xpipe/app/pwman/KeePassXcAssociationComp.java new file mode 100644 index 000000000..38b901144 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/pwman/KeePassXcAssociationComp.java @@ -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; + } +} diff --git a/app/src/main/java/io/xpipe/app/pwman/KeePassXcPasswordManager.java b/app/src/main/java/io/xpipe/app/pwman/KeePassXcPasswordManager.java index 13d35a262..e1e5570ba 100644 --- a/app/src/main/java/io/xpipe/app/pwman/KeePassXcPasswordManager.java +++ b/app/src/main/java/io/xpipe/app/pwman/KeePassXcPasswordManager.java @@ -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 associationKeys; @SuppressWarnings("unused") public static OptionsBuilder createOptions(Property p) { - var prop = new SimpleObjectProperty(); + var prop = FXCollections.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; diff --git a/app/src/main/java/io/xpipe/app/pwman/KeePassXcProxyClient.java b/app/src/main/java/io/xpipe/app/pwman/KeePassXcProxyClient.java index dfbaaf55c..41e056604 100644 --- a/app/src/main/java/io/xpipe/app/pwman/KeePassXcProxyClient.java +++ b/app/src/main/java/io/xpipe/app/pwman/KeePassXcProxyClient.java @@ -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 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 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 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 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); } } diff --git a/app/src/main/java/io/xpipe/app/util/AppJacksonModule.java b/app/src/main/java/io/xpipe/app/util/AppJacksonModule.java index 26b2a6411..e4b0fdb29 100644 --- a/app/src/main/java/io/xpipe/app/util/AppJacksonModule.java +++ b/app/src/main/java/io/xpipe/app/util/AppJacksonModule.java @@ -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 { + + @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 { + + @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) JacksonMapper.getDefault().treeToValue(tree.required("associationKeys"), javaType); + return KeePassXcPasswordManager.builder().associationKeys(parsed).build(); + } + } } public static class OsTypeSerializer extends JsonSerializer { diff --git a/lang/strings/translations_en.properties b/lang/strings/translations_en.properties index fac684a87..f9c0fc77b 100644 --- a/lang/strings/translations_en.properties +++ b/lang/strings/translations_en.properties @@ -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.