diff --git a/app/src/main/java/io/xpipe/app/beacon/impl/AskpassExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/AskpassExchangeImpl.java index 1f88f8a66..f482e24fb 100644 --- a/app/src/main/java/io/xpipe/app/beacon/impl/AskpassExchangeImpl.java +++ b/app/src/main/java/io/xpipe/app/beacon/impl/AskpassExchangeImpl.java @@ -46,8 +46,12 @@ public class AskpassExchangeImpl extends AskpassExchange { return Response.builder().value(InPlaceSecretValue.of("")).build(); } + var prompt = msg.getPrompt(); + // sudo-rs uses a different prefix which we don't really need + prompt = prompt.replace("[sudo: authenticate]", "[sudo]"); + if (msg.getRequest() == null) { - var r = AskpassAlert.queryRaw(msg.getPrompt(), null, true); + var r = AskpassAlert.queryRaw(prompt, null, true); return Response.builder().value(r.getSecret()).build(); } @@ -59,7 +63,7 @@ public class AskpassExchangeImpl extends AskpassExchange { } var p = found.get(); - var secret = p.process(msg.getPrompt()); + var secret = p.process(prompt); if (p.getState() != SecretQueryState.NORMAL) { var ex = new BeaconClientException(SecretQueryState.toErrorMessage(p.getState())); ErrorEventFactory.preconfigure(ErrorEventFactory.fromThrowable(ex).ignore()); diff --git a/app/src/main/java/io/xpipe/app/comp/base/AppMainWindowContentComp.java b/app/src/main/java/io/xpipe/app/comp/base/AppMainWindowContentComp.java index 3aed448a7..3a4fa15b5 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/AppMainWindowContentComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/AppMainWindowContentComp.java @@ -100,7 +100,7 @@ public class AppMainWindowContentComp extends SimpleComp { }); var loadingTextCounter = new SimpleIntegerProperty(); - GlobalTimer.scheduleUntil(Duration.ofMillis(300), false, () -> { + GlobalTimer.scheduleUntil(Duration.ofMillis(500), false, () -> { if (loaded.getValue() != null) { return true; } @@ -146,6 +146,16 @@ public class AppMainWindowContentComp extends SimpleComp { PlatformThread.runNestedLoopIteration(); struc.show(); TrackEvent.info("Window content node shown"); + } else if (!pane.getChildren().contains(vbox)) { + loadingTextCounter.set(3); + TrackEvent.info("Window content node removed"); + PlatformThread.runNestedLoopIteration(); + pane.getChildren().clear(); + pane.getStyleClass().add("background"); + pane.getChildren().add(vbox); + sidebarPresent.set(false); + loadingAnimation.start(); + PlatformThread.runNestedLoopIteration(); } }); diff --git a/app/src/main/java/io/xpipe/app/comp/base/MarkdownComp.java b/app/src/main/java/io/xpipe/app/comp/base/MarkdownComp.java index 2470b83e3..8c0168d55 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/MarkdownComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/MarkdownComp.java @@ -3,13 +3,13 @@ package io.xpipe.app.comp.base; import io.xpipe.app.comp.Comp; import io.xpipe.app.comp.CompStructure; import io.xpipe.app.comp.SimpleCompStructure; +import io.xpipe.app.core.AppCache; import io.xpipe.app.core.AppProperties; import io.xpipe.app.core.AppResources; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.platform.MarkdownHelper; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.prefs.AppPrefs; -import io.xpipe.app.process.ShellTemp; import io.xpipe.app.util.Hyperlinks; import io.xpipe.core.OsType; @@ -34,7 +34,7 @@ import java.util.function.UnaryOperator; public class MarkdownComp extends Comp> { private static Boolean WEB_VIEW_SUPPORTED; - private static Path TEMP; + private static Path DIR; private final ObservableValue markdown; private final UnaryOperator htmlTransformation; private final boolean bodyPadding; @@ -53,8 +53,8 @@ public class MarkdownComp extends Comp> { } private Path getHtmlFile(String markdown) { - if (TEMP == null) { - TEMP = ShellTemp.getLocalTempDataDirectory("webview"); + if (DIR == null) { + DIR = AppCache.getBasePath().resolve("md"); } if (markdown == null) { @@ -68,7 +68,7 @@ public class MarkdownComp extends Comp> { } else { hash = markdown.hashCode(); } - var file = TEMP.resolve("md-" + hash + ".html"); + var file = DIR.resolve("md-" + hash + ".html"); if (Files.exists(file)) { return file; } @@ -94,7 +94,7 @@ public class MarkdownComp extends Comp> { wv.setPageFill(Color.TRANSPARENT); wv.getEngine() .setUserDataDirectory( - AppProperties.get().getDataDir().resolve("webview").toFile()); + AppCache.getBasePath().resolve("webview").toFile()); var theme = AppPrefs.get() != null && AppPrefs.get().theme().getValue() != null && AppPrefs.get().theme().getValue().isDark() diff --git a/app/src/main/java/io/xpipe/app/comp/base/ModalOverlayComp.java b/app/src/main/java/io/xpipe/app/comp/base/ModalOverlayComp.java index 8f6aabf76..a9407b2ba 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/ModalOverlayComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/ModalOverlayComp.java @@ -214,12 +214,10 @@ public class ModalOverlayComp extends SimpleComp { max.set(d); } }); - } - node.minWidthProperty().bind(max); - buttonBar.getChildren().add(node); - if (o instanceof ModalButton) { + node.minWidthProperty().bind(max); node.prefHeightProperty().bind(buttonBar.heightProperty()); } + buttonBar.getChildren().add(node); } content.getChildren().add(buttonBar); AppFontSizes.apply(buttonBar, sizes -> { diff --git a/app/src/main/java/io/xpipe/app/comp/base/SecretFieldComp.java b/app/src/main/java/io/xpipe/app/comp/base/SecretFieldComp.java index 2c294f636..74b367cf5 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/SecretFieldComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/SecretFieldComp.java @@ -94,16 +94,16 @@ public class SecretFieldComp extends Comp { } field.setText(n != null ? n.getSecretValue() : null); - }); - var capslock = Platform.isKeyLocked(KeyCode.CAPS); - if (!capslock.orElse(false)) { - capsPopover.hide(); - return; - } + var capslock = Platform.isKeyLocked(KeyCode.CAPS); + if (!capslock.orElse(false)) { + capsPopover.hide(); + return; + } if (!capsPopover.isShowing() && field.getScene() != null) { - capsPopover.show(field); - } + capsPopover.show(field); + } + }); }); HBox.setHgrow(field, Priority.ALWAYS); diff --git a/app/src/main/java/io/xpipe/app/core/AppI18n.java b/app/src/main/java/io/xpipe/app/core/AppI18n.java index 99add4082..f1afb9e7a 100644 --- a/app/src/main/java/io/xpipe/app/core/AppI18n.java +++ b/app/src/main/java/io/xpipe/app/core/AppI18n.java @@ -52,6 +52,10 @@ public class AppI18n { return INSTANCE.observableImpl(s, vars); } + public static ObservableValue observable(ObservableValue s, Object... vars) { + return BindingsHelper.flatMap(s, v -> INSTANCE.observableImpl(v, vars)); + } + public static String get(String s, Object... vars) { return INSTANCE.getLocalised(s, vars); } diff --git a/app/src/main/java/io/xpipe/app/core/mode/AppBaseMode.java b/app/src/main/java/io/xpipe/app/core/mode/AppBaseMode.java index 24878152a..f0b171c66 100644 --- a/app/src/main/java/io/xpipe/app/core/mode/AppBaseMode.java +++ b/app/src/main/java/io/xpipe/app/core/mode/AppBaseMode.java @@ -123,6 +123,7 @@ public class AppBaseMode extends AppOperationMode { syncPrefsLoaded.countDown(); AppMainWindow.loadingText("loadingConnections"); DataStorage.init(); + AppPrefs.initStorage(); storageLoaded.countDown(); AppMcpServer.init(); StoreViewState.init(); diff --git a/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java b/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java index 33bfe7446..a639b54c8 100644 --- a/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java +++ b/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java @@ -129,7 +129,7 @@ public class AppMainWindow { } public static void loadingText(String key) { - loadingText.setValue(key != null && AppI18n.get() != null ? AppI18n.get(key) : "..."); + loadingText.setValue(key != null && AppI18n.get() != null ? AppI18n.get(key) : "?"); } public static synchronized void initContent() { @@ -146,6 +146,13 @@ public class AppMainWindow { }); } + public static synchronized void resetContent() { + PlatformThread.runLaterIfNeededBlocking(() -> { + loadingText.setValue(AppI18n.get("savingChanges")); + loadedContent.setValue(null); + }); + } + public static AppMainWindow get() { return INSTANCE; } 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 b09c84efa..8fe38a263 100644 --- a/app/src/main/java/io/xpipe/app/platform/OptionsBuilder.java +++ b/app/src/main/java/io/xpipe/app/platform/OptionsBuilder.java @@ -164,6 +164,10 @@ public class OptionsBuilder { return name(key).description(key + "Description"); } + public OptionsBuilder nameAndDescription(ObservableValue key) { + return name(AppI18n.observable(key)).description(AppI18n.observable(BindingsHelper.map(key, k -> k + "Description"))); + } + public OptionsBuilder subAdvanced(OptionsBuilder builder) { name("advanced"); subExpandable("showAdvancedOptions", builder); @@ -374,6 +378,7 @@ public class OptionsBuilder { public OptionsBuilder name(ObservableValue name) { finishCurrent(); this.name = name; + lastNameReference = name; return this; } diff --git a/app/src/main/java/io/xpipe/app/platform/OptionsChoiceBuilder.java b/app/src/main/java/io/xpipe/app/platform/OptionsChoiceBuilder.java index ba06ffc25..1e0a70249 100644 --- a/app/src/main/java/io/xpipe/app/platform/OptionsChoiceBuilder.java +++ b/app/src/main/java/io/xpipe/app/platform/OptionsChoiceBuilder.java @@ -27,7 +27,6 @@ public class OptionsChoiceBuilder { private final Property property; private final List> available; - private final List> selectable; private final Function, Region> transformer; private final boolean allowNull; private final Object customConfiguration; diff --git a/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java b/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java index 5679b4729..2eac63aba 100644 --- a/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java +++ b/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java @@ -14,6 +14,8 @@ import io.xpipe.app.process.ShellScript; import io.xpipe.app.pwman.PasswordManager; import io.xpipe.app.rdp.ExternalRdpClient; import io.xpipe.app.storage.DataStorage; +import io.xpipe.app.storage.DataStorageGroupStrategy; +import io.xpipe.app.storage.DataStorageUserHandler; import io.xpipe.app.terminal.ExternalTerminalType; import io.xpipe.app.terminal.TerminalMultiplexer; import io.xpipe.app.terminal.TerminalPrompt; @@ -40,6 +42,7 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.Value; +import org.w3c.dom.UserDataHandler; import java.nio.file.Files; import java.util.*; @@ -60,6 +63,7 @@ public final class AppPrefs { public static void initSynced() throws Exception { INSTANCE.loadSharedRemote(); + INSTANCE.fixSyncedValues(); INSTANCE.encryptAllVaultData.addListener((observableValue, aBoolean, t1) -> { if (DataStorage.get() != null) { DataStorage.get().forceRewrite(); @@ -67,6 +71,10 @@ public final class AppPrefs { }); } + public static void initStorage() { + INSTANCE.vaultAuthentication.set(DataStorageUserHandler.getInstance().getVaultAuthenticationType()); + } + public static void reset() { INSTANCE.save(); @@ -269,6 +277,16 @@ public final class AppPrefs { .log(false) .documentationLink(DocumentationLink.TERMINAL_PROMPT) .build()); + final ObjectProperty vaultAuthentication = new GlobalObjectProperty<>(); + + final ObjectProperty groupSecretStrategy = map(Mapping.builder() + .property(new GlobalObjectProperty<>()) + .key("groupSecretStrategy") + .valueClass(DataStorageGroupStrategy.class) + .requiresRestart(true) + .vaultSpecific(true) + .licenseFeatureId("team") + .build()); final ObjectProperty startupBehaviour = map(Mapping.builder() .property(new GlobalObjectProperty<>(StartupBehaviour.GUI)) .key("startupBehaviour") @@ -415,6 +433,14 @@ public final class AppPrefs { private AppPrefs() {} + public ObservableValue vaultAuthentication() { + return vaultAuthentication; + } + + public ObservableValue groupSecretStrategy() { + return groupSecretStrategy; + } + public ObservableStringValue notesTemplate() { return notesTemplate; } @@ -709,7 +735,10 @@ public final class AppPrefs { PlatformThread.runLaterIfNeededBlocking(() -> { writable.setValue(newValue); }); - save(); + + if (mapping.stream().anyMatch(m -> m.property == prop)) { + save(); + } } private void fixLocalValues() { @@ -751,6 +780,17 @@ public final class AppPrefs { PrefsProvider.getAll().forEach(prov -> prov.fixLocalValues()); } + private void fixSyncedValues() { + if (groupSecretStrategy.getValue() != null) { + try { + groupSecretStrategy.get().checkComplete(); + } catch (Exception e) { + groupSecretStrategy.setValue(null); + ErrorEventFactory.fromThrowable(e).omit().expected().handle(); + } + } + } + public void initDefaultValues() { externalEditor.setValue(ExternalEditorType.determineDefault(externalEditor.get())); terminalType.set(ExternalTerminalType.determineDefault(terminalType.get())); diff --git a/app/src/main/java/io/xpipe/app/prefs/VaultAuthentication.java b/app/src/main/java/io/xpipe/app/prefs/VaultAuthentication.java new file mode 100644 index 000000000..ebd1df482 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/prefs/VaultAuthentication.java @@ -0,0 +1,16 @@ +package io.xpipe.app.prefs; + +import io.xpipe.app.core.mode.AppOperationMode; +import io.xpipe.app.ext.PrefsChoiceValue; +import io.xpipe.core.XPipeDaemonMode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum VaultAuthentication implements PrefsChoiceValue { + USER("userAuth"), + GROUP("groupAuth"); + + private final String id; +} diff --git a/app/src/main/java/io/xpipe/app/prefs/VaultCategory.java b/app/src/main/java/io/xpipe/app/prefs/VaultCategory.java index e23affd9b..0e4af8d27 100644 --- a/app/src/main/java/io/xpipe/app/prefs/VaultCategory.java +++ b/app/src/main/java/io/xpipe/app/prefs/VaultCategory.java @@ -2,22 +2,30 @@ package io.xpipe.app.prefs; import io.xpipe.app.comp.Comp; import io.xpipe.app.comp.base.ButtonComp; +import io.xpipe.app.comp.base.ChoiceComp; import io.xpipe.app.comp.base.ModalButton; import io.xpipe.app.comp.base.ModalOverlay; import io.xpipe.app.core.AppI18n; import io.xpipe.app.core.window.AppDialog; +import io.xpipe.app.platform.GlobalObjectProperty; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.platform.OptionsBuilder; +import io.xpipe.app.platform.OptionsChoiceBuilder; +import io.xpipe.app.storage.DataStorageGroupStrategy; import io.xpipe.app.storage.DataStorageSyncHandler; import io.xpipe.app.storage.DataStorageUserHandler; import io.xpipe.app.util.DocumentationLink; import io.xpipe.app.util.LicenseProvider; import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleBooleanProperty; import lombok.SneakyThrows; +import java.util.Arrays; + public class VaultCategory extends AppPrefsCategory { @Override @@ -57,34 +65,50 @@ public class VaultCategory extends AppPrefsCategory { var uh = DataStorageUserHandler.getInstance(); var vaultTypeKey = uh.getUserCount() == 0 ? "Default" - : uh.getUserCount() == 1 + : uh.getUserCount() == 1 && uh.getVaultAuthenticationType() != VaultAuthentication.GROUP ? (uh.getActiveUser() != null && uh.getActiveUser().equals("legacy") ? "Legacy" : "Personal") : "Team"; + var authChoice = ChoiceComp.ofTranslatable(prefs.vaultAuthentication, Arrays.asList(VaultAuthentication.values()), true); + authChoice.apply(struc -> struc.get().setOpacity(1.0)); + authChoice.maxWidth(600); + builder.addTitle("vault") .sub(new OptionsBuilder() .name("vaultTypeName" + vaultTypeKey) .description("vaultTypeContent" + vaultTypeKey) .documentationLink(DocumentationLink.TEAM_VAULTS) .addComp(Comp.empty()) - .name("userManagement") - .description( - uh.getActiveUser() != null - ? "userManagementDescription" - : "userManagementDescriptionEmpty") + .licenseRequirement("team") + .nameAndDescription("vaultAuthentication") + .addComp(authChoice, prefs.vaultAuthentication) + .nameAndDescription(Bindings.createStringBinding(() -> { + var empty = uh.getUserCount() == 0; + if (prefs.vaultAuthentication.get() == VaultAuthentication.GROUP) { + return empty ? "groupManagementEmpty" : "groupManagement"; + } + + return empty ? "userManagementEmpty" : "userManagement"; + }, prefs.vaultAuthentication)) .addComp(uh.createOverview().maxWidth(getCompWidth())) + .pref(prefs.groupSecretStrategy) + .addComp(OptionsChoiceBuilder.builder().property(prefs.groupSecretStrategy) + .allowNull(true).available(DataStorageGroupStrategy.getClasses()) + .build().build().buildComp().maxWidth(getCompWidth()), + prefs.groupSecretStrategy) + .nonNull() + .hide(prefs.vaultAuthentication.isNotEqualTo(VaultAuthentication.GROUP)) + .nameAndDescription("syncVault") + .addComp(new ButtonComp(AppI18n.observable("enableGitSync"), () -> AppPrefs.get() + .selectCategory("vaultSync"))) + .hide(new SimpleBooleanProperty( + DataStorageSyncHandler.getInstance().supportsSync())) .nameAndDescription("teamVaults") .addComp(Comp.empty()) .licenseRequirement("team") .disable(!LicenseProvider.get().getFeature("team").isSupported()) - .hide(new SimpleBooleanProperty(uh.getUserCount() > 1)) - .nameAndDescription("syncTeamVaults") - .addComp(new ButtonComp(AppI18n.observable("enableGitSync"), () -> AppPrefs.get() - .selectCategory("vaultSync"))) - .licenseRequirement("team") - .disable(!LicenseProvider.get().getFeature("team").isSupported()) - .hide(new SimpleBooleanProperty( - DataStorageSyncHandler.getInstance().supportsSync()))); + .hide(uh.getUserCount() > 1) + ); builder.sub(new OptionsBuilder().pref(prefs.encryptAllVaultData).addToggle(encryptVault)); return builder.buildComp(); } diff --git a/app/src/main/java/io/xpipe/app/process/CommandControl.java b/app/src/main/java/io/xpipe/app/process/CommandControl.java index 0e4e20445..779e42276 100644 --- a/app/src/main/java/io/xpipe/app/process/CommandControl.java +++ b/app/src/main/java/io/xpipe/app/process/CommandControl.java @@ -1,5 +1,7 @@ package io.xpipe.app.process; +import io.xpipe.app.util.GlobalTimer; +import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.FilePath; import java.io.InputStream; @@ -71,6 +73,21 @@ public interface CommandControl extends ProcessControl { Optional readStdoutIfPossible() throws Exception; + default void killOnTimeout(CountDown countDown) { + GlobalTimer.scheduleUntil(Duration.ofSeconds(1), false, () -> { + if (!isRunning(true)) { + return false; + } + + if (!countDown.countDown()) { + kill(); + return false; + } + + return true; + }); + } + default boolean discardAndCheckExit() throws ProcessOutputException { try { discardOrThrow(); diff --git a/app/src/main/java/io/xpipe/app/process/CountDown.java b/app/src/main/java/io/xpipe/app/process/CountDown.java index 7840e3e9c..c1480bf3c 100644 --- a/app/src/main/java/io/xpipe/app/process/CountDown.java +++ b/app/src/main/java/io/xpipe/app/process/CountDown.java @@ -20,11 +20,12 @@ public class CountDown { return new CountDown(); } - public synchronized void start(long millisecondsLeft) { + public synchronized CountDown start(long millisecondsLeft) { this.millisecondsLeft = millisecondsLeft; this.maxMillis = millisecondsLeft; lastMillis = System.currentTimeMillis(); active = true; + return this; } public void pause() { diff --git a/app/src/main/java/io/xpipe/app/secret/EncryptionToken.java b/app/src/main/java/io/xpipe/app/secret/EncryptionToken.java index 1d9de5bcd..ccc92c556 100644 --- a/app/src/main/java/io/xpipe/app/secret/EncryptionToken.java +++ b/app/src/main/java/io/xpipe/app/secret/EncryptionToken.java @@ -18,7 +18,10 @@ import javax.crypto.SecretKey; public class EncryptionToken { private static EncryptionToken vaultToken; + private static EncryptionToken groupToken; private static EncryptionToken userToken; + + private final String token; @JsonIgnore diff --git a/app/src/main/java/io/xpipe/app/secret/SecretRetrievalStrategy.java b/app/src/main/java/io/xpipe/app/secret/SecretRetrievalStrategy.java index b1cef493d..670293193 100644 --- a/app/src/main/java/io/xpipe/app/secret/SecretRetrievalStrategy.java +++ b/app/src/main/java/io/xpipe/app/secret/SecretRetrievalStrategy.java @@ -9,16 +9,9 @@ import java.util.ArrayList; import java.util.List; @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") -@JsonSubTypes({ - @JsonSubTypes.Type(value = SecretNoneStrategy.class), - @JsonSubTypes.Type(value = SecretInPlaceStrategy.class), - @JsonSubTypes.Type(value = SecretPromptStrategy.class), - @JsonSubTypes.Type(value = SecretCustomCommandStrategy.class), - @JsonSubTypes.Type(value = SecretPasswordManagerStrategy.class) -}) public interface SecretRetrievalStrategy { - static List> getSubclasses() { + static List> getClasses() { var l = new ArrayList>(); l.add(SecretNoneStrategy.class); l.add(SecretInPlaceStrategy.class); diff --git a/app/src/main/java/io/xpipe/app/storage/DataStorageGroupStrategy.java b/app/src/main/java/io/xpipe/app/storage/DataStorageGroupStrategy.java new file mode 100644 index 000000000..fd27f4f23 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/storage/DataStorageGroupStrategy.java @@ -0,0 +1,270 @@ +package io.xpipe.app.storage; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.xpipe.app.comp.base.*; +import io.xpipe.app.core.App; +import io.xpipe.app.ext.ProcessControlProvider; +import io.xpipe.app.ext.ValidationException; +import io.xpipe.app.issue.ErrorEventFactory; +import io.xpipe.app.platform.OptionsBuilder; +import io.xpipe.app.prefs.AppPrefs; +import io.xpipe.app.process.CountDown; +import io.xpipe.app.process.ShellScript; +import io.xpipe.app.secret.SecretPasswordManagerStrategy; +import io.xpipe.app.secret.SecretQueryState; +import io.xpipe.app.util.HttpHelper; +import io.xpipe.app.util.Validators; +import javafx.beans.binding.Bindings; +import javafx.beans.property.Property; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; +import org.kordamp.ikonli.javafx.FontIcon; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +public interface DataStorageGroupStrategy { + + static List> getClasses() { + var l = new ArrayList>(); + l.add(PasswordManager.class); + l.add(File.class); + l.add(Command.class); + l.add(HttpRequest.class); + return l; + } + + default void checkComplete() throws ValidationException {} + + String queryEncryptionSecret() throws Exception; + + @JsonTypeName("passwordManager") + @Builder + @Jacksonized + @Value + public class PasswordManager implements DataStorageGroupStrategy { + + @SuppressWarnings("unused") + public static String getOptionsNameKey() { + return "passwordManager"; + } + + @SuppressWarnings("unused") + public static OptionsBuilder createOptions(Property p) { + var key = new SimpleStringProperty(p.getValue().getKey()); + + var prefs = AppPrefs.get(); + var content = new HorizontalComp(List.of( + new TextFieldComp(key) + .apply(struc -> struc.get() + .promptTextProperty() + .bind(Bindings.createStringBinding( + () -> { + return prefs.passwordManager() + .getValue() + != null + ? prefs.passwordManager() + .getValue() + .getKeyPlaceholder() + : "?"; + }, + prefs.passwordManager()))) + .hgrow(), + new ButtonComp(null, new FontIcon("mdomz-settings"), () -> { + AppPrefs.get().selectCategory("passwordManager"); + App.getApp().getStage().requestFocus(); + }) + .grow(false, true))) + .apply(struc -> struc.get().setSpacing(10)) + .apply(struc -> struc.get().focusedProperty().addListener((c, o, n) -> { + if (n) { + struc.get().getChildren().getFirst().requestFocus(); + } + })); + + return new OptionsBuilder() + .nameAndDescription("passwordManagerKey") + .addString(key) + .nonNull() + .bind( + () -> { + return PasswordManager.builder().key(key.get()).build(); + }, + p); + } + + String key; + + @Override + public void checkComplete() throws ValidationException { + Validators.nonNull(key); + } + + @Override + public String queryEncryptionSecret() throws Exception { + var r = SecretPasswordManagerStrategy.builder().key(key).build().query().query("Group secret"); + return r.getState() == SecretQueryState.NORMAL ? r.getSecret().getSecretValue() : null; + } + } + + @JsonTypeName("file") + @Builder + @Jacksonized + @Value + public class File implements DataStorageGroupStrategy { + + @SuppressWarnings("unused") + public static String getOptionsNameKey() { + return "fileSecret"; + } + + @SuppressWarnings("unused") + public static OptionsBuilder createOptions(Property p) { + var file = new SimpleObjectProperty<>(p.getValue().getFile() != null ? p.getValue().getFile().toLocalAbsoluteFilePath() : null); + return new OptionsBuilder() + .nameAndDescription("fileSecretChoice") + .addComp(new ContextualFileReferenceChoiceComp( + new ReadOnlyObjectWrapper<>(DataStorage.get().local().ref()), + file, null, List.of(), e -> e.equals(DataStorage.get().local())), file) + .nonNull() + .bind( + () -> { + return File.builder().file(ContextualFileReference.of(file.get())).build(); + }, + p); + } + + ContextualFileReference file; + + @Override + public void checkComplete() throws ValidationException { + Validators.nonNull(file); + } + + @Override + public String queryEncryptionSecret() throws Exception { + var abs = file.toLocalAbsoluteFilePath().asLocalPath(); + if (!Files.exists(abs)) { + throw ErrorEventFactory.expected(new IllegalArgumentException("Group key file " + file + " does not exist")); + } + + var read = Files.readString(abs); + if (read.length() == 0) { + throw ErrorEventFactory.expected(new IllegalArgumentException("Group key file " + file + " is empty")); + } + + return read; + } + } + + @JsonTypeName("command") + @Builder + @Jacksonized + @Value + public class Command implements DataStorageGroupStrategy { + + @SuppressWarnings("unused") + public static String getOptionsNameKey() { + return "commandSecret"; + } + + @SuppressWarnings("unused") + public static OptionsBuilder createOptions(Property p) { + var command = new SimpleStringProperty(p.getValue().getCommand() != null ? + p.getValue().getCommand().getValue() : null); + return new OptionsBuilder() + .nameAndDescription("commandSecretField") + .addComp(new TextAreaComp(command), command) + .nonNull() + .bind( + () -> { + return Command.builder().command(command.get() != null ? new ShellScript( + command.get()) : null).build(); + }, + p); + } + + ShellScript command; + + @Override + public void checkComplete() throws ValidationException { + Validators.nonNull(command); + } + + @Override + public String queryEncryptionSecret() throws Exception { + try (var sc = ProcessControlProvider.get().createLocalProcessControl(true)) { + try (var cc = sc.command(command).start()) { + cc.killOnTimeout(CountDown.of().start(30_000)); + var out = cc.readStdoutOrThrow(); + if (out.length() == 0) { + throw ErrorEventFactory.expected(new IllegalArgumentException("Command did not return any output")); + } + return out; + } + } + } + } + + + @JsonTypeName("httpRequest") + @Builder + @Jacksonized + @Value + public class HttpRequest implements DataStorageGroupStrategy { + + @SuppressWarnings("unused") + public static String getOptionsNameKey() { + return "httpRequestSecret"; + } + + @SuppressWarnings("unused") + public static OptionsBuilder createOptions(Property p) { + var uri = new SimpleStringProperty(p.getValue().getUri()); + return new OptionsBuilder() + .nameAndDescription("httpRequestSecretField") + .addString(uri) + .nonNull() + .bind( + () -> { + return HttpRequest.builder().uri(uri.get()).build(); + }, + p); + } + + String uri; + + @Override + public void checkComplete() throws ValidationException { + Validators.nonNull(uri); + } + + @Override + public String queryEncryptionSecret() throws Exception { + var uri = URI.create(getUri()); + var request = java.net.http.HttpRequest.newBuilder() + .uri(uri) + .POST(java.net.http.HttpRequest.BodyPublishers.noBody()) + .build(); + var result = HttpHelper.client().send(request, HttpResponse.BodyHandlers.ofString()); + if (result.statusCode() >= 400) { + throw ErrorEventFactory.expected(new IOException(result.body())); + } + var body = result.body(); + if (body.length() == 0) { + throw ErrorEventFactory.expected(new IllegalArgumentException("Http response body is empty")); + } + return body; + } + } +} diff --git a/app/src/main/java/io/xpipe/app/storage/DataStorageUserHandler.java b/app/src/main/java/io/xpipe/app/storage/DataStorageUserHandler.java index aa6d10b08..4d1ba8fbc 100644 --- a/app/src/main/java/io/xpipe/app/storage/DataStorageUserHandler.java +++ b/app/src/main/java/io/xpipe/app/storage/DataStorageUserHandler.java @@ -2,6 +2,7 @@ package io.xpipe.app.storage; import io.xpipe.app.comp.Comp; import io.xpipe.app.ext.ProcessControlProvider; +import io.xpipe.app.prefs.VaultAuthentication; import java.io.IOException; import javax.crypto.SecretKey; @@ -25,4 +26,6 @@ public interface DataStorageUserHandler { Comp createOverview(); String getActiveUser(); + + VaultAuthentication getVaultAuthenticationType(); } diff --git a/app/src/main/java/io/xpipe/app/storage/StandardStorage.java b/app/src/main/java/io/xpipe/app/storage/StandardStorage.java index 7c6387d38..f66e61f18 100644 --- a/app/src/main/java/io/xpipe/app/storage/StandardStorage.java +++ b/app/src/main/java/io/xpipe/app/storage/StandardStorage.java @@ -2,6 +2,7 @@ package io.xpipe.app.storage; import io.xpipe.app.core.AppProperties; import io.xpipe.app.core.mode.AppOperationMode; +import io.xpipe.app.core.window.AppMainWindow; import io.xpipe.app.ext.DataStorageExtensionProvider; import io.xpipe.app.ext.LocalStore; import io.xpipe.app.issue.ErrorEventFactory; @@ -314,6 +315,11 @@ public class StandardStorage extends DataStorage { .build() .handle(); } + + if (dataStorageUserHandler.getUserCount() > 0) { + AppMainWindow.loadingText("unlockingVault"); + } + dataStorageUserHandler.login(); reloadContent(); 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 e4b0fdb29..f29a50ace 100644 --- a/app/src/main/java/io/xpipe/app/util/AppJacksonModule.java +++ b/app/src/main/java/io/xpipe/app/util/AppJacksonModule.java @@ -8,10 +8,7 @@ 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; -import io.xpipe.app.secret.EncryptionToken; -import io.xpipe.app.secret.PasswordLockSecretValue; -import io.xpipe.app.secret.VaultKeySecretValue; +import io.xpipe.app.secret.*; import io.xpipe.app.storage.*; import io.xpipe.app.terminal.ExternalTerminalType; import io.xpipe.app.terminal.TerminalMultiplexer; @@ -86,6 +83,8 @@ public class AppJacksonModule extends SimpleModule { context.registerSubtypes(TerminalPrompt.getClasses()); context.registerSubtypes(ExternalVncClient.getClasses()); context.registerSubtypes(ExternalRdpClient.getClasses()); + context.registerSubtypes(SecretRetrievalStrategy.getClasses()); + context.registerSubtypes(DataStorageGroupStrategy.getClasses()); super.setupModule(context); } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/IdentityChoiceBuilder.java b/ext/base/src/main/java/io/xpipe/ext/base/identity/IdentityChoiceBuilder.java index ec2f54404..e9f6bb820 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/IdentityChoiceBuilder.java +++ b/ext/base/src/main/java/io/xpipe/ext/base/identity/IdentityChoiceBuilder.java @@ -96,7 +96,7 @@ public class IdentityChoiceBuilder { .property(pass) .customConfiguration( SecretStrategyChoiceConfig.builder().allowNone(true).build()) - .available(SecretRetrievalStrategy.getSubclasses()) + .available(SecretRetrievalStrategy.getClasses()) .build() .build(); diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/LocalIdentityStoreProvider.java b/ext/base/src/main/java/io/xpipe/ext/base/identity/LocalIdentityStoreProvider.java index a30a715b1..c8057c546 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/LocalIdentityStoreProvider.java +++ b/ext/base/src/main/java/io/xpipe/ext/base/identity/LocalIdentityStoreProvider.java @@ -51,7 +51,7 @@ public class LocalIdentityStoreProvider extends IdentityStoreProvider { .property(pass) .customConfiguration( SecretStrategyChoiceConfig.builder().allowNone(true).build()) - .available(SecretRetrievalStrategy.getSubclasses()) + .available(SecretRetrievalStrategy.getClasses()) .build() .build(); diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/SyncedIdentityStoreProvider.java b/ext/base/src/main/java/io/xpipe/ext/base/identity/SyncedIdentityStoreProvider.java index c212698ab..e3c3c9379 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/SyncedIdentityStoreProvider.java +++ b/ext/base/src/main/java/io/xpipe/ext/base/identity/SyncedIdentityStoreProvider.java @@ -8,6 +8,7 @@ import io.xpipe.app.hub.comp.StoreEntryWrapper; import io.xpipe.app.platform.OptionsBuilder; import io.xpipe.app.platform.OptionsChoiceBuilder; import io.xpipe.app.platform.Validator; +import io.xpipe.app.prefs.VaultAuthentication; import io.xpipe.app.secret.EncryptedValue; import io.xpipe.app.secret.SecretNoneStrategy; import io.xpipe.app.secret.SecretRetrievalStrategy; @@ -83,10 +84,11 @@ public class SyncedIdentityStoreProvider extends IdentityStoreProvider { .property(pass) .customConfiguration( SecretStrategyChoiceConfig.builder().allowNone(true).build()) - .available(SecretRetrievalStrategy.getSubclasses()) + .available(SecretRetrievalStrategy.getClasses()) .build() .build(); + var handler = DataStorageUserHandler.getInstance(); return new OptionsBuilder() .nameAndDescription("username") .addString(user) @@ -104,11 +106,11 @@ public class SyncedIdentityStoreProvider extends IdentityStoreProvider { return !wrong; })) .nameAndDescription( - DataStorageUserHandler.getInstance().getActiveUser() != null - ? "identityPerUser" + handler.getActiveUser() != null + ? (handler.getVaultAuthenticationType() == VaultAuthentication.GROUP ? "identityPerGroup" : "identityPerUser") : "identityPerUserDisabled") .addToggle(perUser) - .disable(DataStorageUserHandler.getInstance().getActiveUser() == null) + .disable(handler.getActiveUser() == null) .bind( () -> { return SyncedIdentityStore.builder() diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/InPlaceKeyStrategy.java b/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/InPlaceKeyStrategy.java index 41ea0c4cb..2e6964c59 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/InPlaceKeyStrategy.java +++ b/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/InPlaceKeyStrategy.java @@ -57,7 +57,7 @@ public class InPlaceKeyStrategy implements SshIdentityStrategy { .property(keyPasswordProperty) .customConfiguration( SecretStrategyChoiceConfig.builder().allowNone(true).build()) - .available(SecretRetrievalStrategy.getSubclasses()) + .available(SecretRetrievalStrategy.getClasses()) .build() .build(); var publicKeyField = new TextFieldComp(publicKey).apply(struc -> { diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/KeyFileStrategy.java b/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/KeyFileStrategy.java index 1511a17d5..884fb9419 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/KeyFileStrategy.java +++ b/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/KeyFileStrategy.java @@ -84,7 +84,7 @@ public class KeyFileStrategy implements SshIdentityStrategy { .property(keyPasswordProperty) .customConfiguration( SecretStrategyChoiceConfig.builder().allowNone(true).build()) - .available(SecretRetrievalStrategy.getSubclasses()) + .available(SecretRetrievalStrategy.getClasses()) .build() .build(); diff --git a/lang/strings/translations_en.properties b/lang/strings/translations_en.properties index 0136598f7..4ba4ee10e 100644 --- a/lang/strings/translations_en.properties +++ b/lang/strings/translations_en.properties @@ -570,6 +570,8 @@ identitiesIntroBottomContent=You can add identities locally or also sync them up identitiesIntroBottomButton=Setup sync identitiesIntroButton=Create identity userName=Username +userAuth=User-based password authentication +groupAuth=Group-based secret authentication team=Team teamSettings=Team settings teamVaults=Team vaults @@ -577,20 +579,31 @@ vaultTypeNameDefault=Default vault vaultTypeNameLegacy=Legacy personal vault vaultTypeNamePersonal=Personal vault vaultTypeNameTeam=Team vault -teamVaultsDescription=Team vaults allow multiple users to have secure access to a shared vault. You can configure connections and identities to either be shared for all users or only have them available for your personal user by encrypting them with your personal key. Other vault users can't access your personal connections and identities. +#force +teamVaultsDescription=Team vaults allow multiple users and groups to have secure access to a shared vault. You can configure connections and identities to either be shared for all users or only have them available for individual users and groups by encrypting them with their own key. Other vault users can't access personal and group-based connections and identities if they don't have access to the key. vaultTypeContentDefault=You are currently using a default vault with no user and custom passphrase set. Secrets are encrypted with the local vault key. You can upgrade to a personal vault by creating a vault user account. This allows you to encrypt vault secrets with your own personal passphrase that you have to input on each login to unlock the vault. vaultTypeContentLegacy=You are currently using a legacy personal vault for your user. Secrets are encrypted with your personal passphrase. This legacy compatibility has limited features and can't be upgraded to a team vault in-place. -vaultTypeContentPersonal=You are currently using a personal vault for your user. Secrets are encrypted with your personal passphrase. You can upgrade to a team vault by adding additional vault users. -vaultTypeContentTeam=You are currently using a team vault, which allows multiple users to have secure access to a shared vault. You can configure connections and identities to either be shared for all users or only have them available for your personal user by encrypting them with your personal key. Other vault users can't access your personal connections and identities. +#force +vaultTypeContentPersonal=You are currently using a personal vault for your user. Secrets are encrypted with your personal passphrase. You can upgrade to a team vault by adding additional vault users or add a group-based access configuration. +#force +vaultTypeContentTeam=You are currently using a team vault, which allows multiple users to have secure access to a shared vault. You can configure connections and identities to either be shared for all users or only have them available for your personal user or group by encrypting them with your personal or group key. Other vault users can't access your personal and group-based connections and identities if they don't have access to the key. +groupManagement=Group management +groupManagementEmpty=Group management +groupManagementDescription=Manage existing vault groups or create new ones. Each vault group has its own individual secret key which is used to encrypt connections and identities that should only be available to the group and not to others. +groupManagementEmptyDescription=Manage existing vault groups or create new ones. Each vault group has its own individual secret key which is used to encrypt connections and identities that should only be available to the group and not to others.\n\nGroup-based accounts for a team are supported in the professional plan. +#force userManagement=User management -userManagementDescription=Manage existing vault users or create new ones. -userManagementDescriptionEmpty=Manage existing vault users or create new ones. Create a user for yourself to be able to encrypt connections and identities with your personal key.\n\nA single user account is supported in the community edition. Multiple user accounts for a team are supported in the professional plan. +userManagementEmpty=User management +#force +userManagementDescription=Manage existing vault users or create new ones. Each vault user has its own individual password which is used to encrypt connections and identities that should only be available to the user and not to others. +#force +userManagementEmptyDescription=Manage existing vault users or create new ones. Each vault user has its own individual password which is used to encrypt connections and identities that should only be available to the user and not to others. Create a user for yourself to be able to encrypt connections and identities with your personal key.\n\nA single user account is supported in the community edition. Multiple user accounts for a team are supported in the professional plan. userIntroHeader=User management userIntroContent=Create the first user account for yourself to get started. This allows you to lock this workspace with a password. addReusableIdentity=Add reusable identity users=Users -syncTeamVaults=Team vault synchronization -syncTeamVaultsDescription=To synchronize your vault with multiple team members, enable the git synchronization. +syncVault=Vault synchronization +syncVaultDescription=To synchronize your vault with across multiple systems or with multiple team members, enable the git synchronization for this vault. enableGitSync=Enable git sync browseVault=Vault data browseVaultDescription=You can take a look at the vault directory yourself in your native file manager. Note that external edits are not recommended and can cause a variety of issues. @@ -604,10 +617,14 @@ loadingGit=Syncing with git repo loadingGpg=Starting GnuPG daemon for git loadingSettings=Loading settings loadingConnections=Loading connections +unlockingVault=Unlocking vault loadingUserInterface=Loading user interface ptbNotice=Notice for the public test build userDeletionTitle=User deletion -userDeletionContent=Do you want to delete this vault user? This will reencrypt all your personal identities and connection secrets using the vault key that is available to all users. XPipe will restart to apply the user changes. +#force +userDeletionContent=Do you want to delete this vault user? This will reencrypt all your personal identities and connection secrets using the vault key that is available to all users. This will take a while and XPipe will restart to apply the user changes. +groupDeletionTitle=Group deletion +groupDeletionContent=Do you want to delete this vault group? This will reencrypt all group-only identities and connection secrets using the vault key that is available to all users. This will take a while and XPipe will restart to apply the group changes. killTransfer=Kill transfer destination=Destination configuration=Configuration @@ -799,10 +816,13 @@ identity.displayDescription=Create a reusable identity for connections local=Local shared=Global userDescription=The username or predefined identity to log in as -identityPerUserDescription=Restrict access to this identity and its associated connections to your vault user only +identityAccessLevel=Access level identityPerUser=Personal identity access +identityPerUserDescription=Restrict access to this identity and its associated connections to your vault user only identityPerUserDisabled=Personal identity access (disabled) identityPerUserDisabledDescription=Restrict access to this identity and its associated connections to your vault user only (Requires team to be configured) +identityPerGroup=Group-only identity access +identityPerGroupDescription=Restrict access to this identity and its associated connections to this vault group only library=Library location=Location keyAuthentication=Key-based authentication @@ -1210,20 +1230,25 @@ libvirt=libvirt domains customIp=Custom IP customIpDescription=Override the default local VM IP detection if you use advanced networking automaticallyDetect=Automatically detect -lockCreationAlertTitle=User creation +userAddDialogTitle=User creation +groupAddDialogTitle=Group creation passphrase=Passphrase repeatPassphrase=Repeat passphrase -lockCreationAlertHeader=Create new vault user +groupSecret=Group secret +repeatGroupSecret=Repeat group secret +vaultGroup=Vault group loginAlertTitle=Login required loginAlertHeader=Unlock vault to access your personal connections vaultUser=Vault user #context: dative case me=Me +addGroup=Add group ... +addGroupDescription=Create a new group for this vault addUser=Add user ... addUserDescription=Create a new user for this vault skip=Skip userChangePasswordAlertTitle=Password change -userChangePasswordAlertHeader=Set new password for user +groupChangeSecretAlertTitle=Secret change docs=Documentation lxd.displayName=LXD Container lxd.displayDescription=Connect to a LXD container via lxc @@ -1766,3 +1791,19 @@ identityApplySetStoreIdentity=Connection identity set identityApplySetStoreIdentityDescription=The identity has been configured to be used by the connection identityApplySetStoreIdentityButton=Apply identity generateKey=Generate key +groupSecretStrategy=Group-based access control +groupSecretStrategyDescription=Here you can choose how to retrieve the group secret used for encryption. The retrieval method you choose will be run when a user logs into the vault. Their access level to certain connections is determined by the raw key that the retrieval method returns. +fileSecret=File-based secret +commandSecret=Secret retrieval command +httpRequestSecret=HTTP response +fileSecretChoice=File location +fileSecretChoiceDescription=The path to the file containing the group encryption secret. Since this file is queried on all platforms, you can use ~ in the path to refer to the home directory. The file must be available on all systems you unlock the vault from, otherwise group-based connection decryption will fail. +commandSecretField=Retrieval script +commandSecretFieldDescription=The command that will return the secret encryption key for the current group. The command is run in the local system default shell and the key should be printed to stdout. +httpRequestSecretField=Request URI +httpRequestSecretFieldDescription=The URI to send an HTTP request to. The group secret is taken from the HTTP response body. +vaultAuthentication=Vault authentication +vaultAuthenticationDescription=How to authenticate / unlock the vault data. There are multiple different ways of encrypting and unlocking vault data, depending on who you want to share the vault data with. +groupAuthFailed=Secret authentication failed +userAuthFailed=Password authentication failed +savingChanges=Saving changes