mirror of
https://github.com/xpipe-io/xpipe.git
synced 2026-09-26 10:25:44 +00:00
Merge branch keepassxc-multi
This commit is contained in:
@@ -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> {
|
||||
|
||||
Generated
+8
-3
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user