diff --git a/pom.xml b/pom.xml
index 3b46ca5..453e2f5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -44,6 +44,7 @@
2.20.1
2.0.1-alpha
1.4.0
+ 1.1.0
1.1.1
1.4.2
@@ -103,6 +104,11 @@
kdewallet
${kdewallet.version}
+
+ org.purejava
+ secret-service
+ ${secret-service-02.version}
+
org.purejava
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index 1765bb5..d65d4aa 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -7,6 +7,7 @@
import org.cryptomator.linux.autostart.FreedesktopAutoStartService;
import org.cryptomator.linux.keychain.GnomeKeyringKeychainAccess;
import org.cryptomator.linux.keychain.KDEWalletKeychainAccess;
+import org.cryptomator.linux.keychain.SecretServiceKeychainAccess;
import org.cryptomator.linux.quickaccess.DolphinPlaces;
import org.cryptomator.linux.quickaccess.NautilusBookmarks;
import org.cryptomator.linux.revealpath.DBusSendRevealPathService;
@@ -21,12 +22,13 @@
requires org.purejava.kwallet;
requires org.purejava.portal;
requires de.swiesend.secretservice;
+ requires org.purejava.secret;
requires java.xml;
requires java.net.http;
requires com.fasterxml.jackson.databind;
provides AutoStartProvider with FreedesktopAutoStartService;
- provides KeychainAccessProvider with GnomeKeyringKeychainAccess, KDEWalletKeychainAccess;
+ provides KeychainAccessProvider with SecretServiceKeychainAccess, GnomeKeyringKeychainAccess, KDEWalletKeychainAccess;
provides RevealPathService with DBusSendRevealPathService;
provides TrayMenuController with AppindicatorTrayMenuController;
provides QuickAccessService with NautilusBookmarks, DolphinPlaces;
diff --git a/src/main/java/org/cryptomator/linux/keychain/GnomeKeyringKeychainAccess.java b/src/main/java/org/cryptomator/linux/keychain/GnomeKeyringKeychainAccess.java
index a7a2a91..97ef043 100644
--- a/src/main/java/org/cryptomator/linux/keychain/GnomeKeyringKeychainAccess.java
+++ b/src/main/java/org/cryptomator/linux/keychain/GnomeKeyringKeychainAccess.java
@@ -13,9 +13,13 @@
import java.util.List;
import java.util.Map;
+/**
+ * @deprecated Cryptomator has Secret Service as the successor of KDE Wallet and GNOME keyring as a keychain backend since version 1.19.0
+ */
@Priority(900)
@OperatingSystem(OperatingSystem.Value.LINUX)
@DisplayName("GNOME Keyring")
+@Deprecated(since = "1.7.0")
public class GnomeKeyringKeychainAccess implements KeychainAccessProvider {
private static final Logger LOG = LoggerFactory.getLogger(GnomeKeyringKeychainAccess.class);
diff --git a/src/main/java/org/cryptomator/linux/keychain/KDEWalletKeychainAccess.java b/src/main/java/org/cryptomator/linux/keychain/KDEWalletKeychainAccess.java
index 5db6fa8..71d1f9e 100644
--- a/src/main/java/org/cryptomator/linux/keychain/KDEWalletKeychainAccess.java
+++ b/src/main/java/org/cryptomator/linux/keychain/KDEWalletKeychainAccess.java
@@ -19,9 +19,13 @@
import java.util.Optional;
+/**
+ * @deprecated Cryptomator has Secret Service as the successor of KDE Wallet and GNOME keyring as a keychain backend since version 1.19.0
+ */
@Priority(900)
@OperatingSystem(OperatingSystem.Value.LINUX)
@DisplayName("KDE Wallet")
+@Deprecated(since = "1.7.0")
public class KDEWalletKeychainAccess implements KeychainAccessProvider {
private static final Logger LOG = LoggerFactory.getLogger(KDEWalletKeychainAccess.class);
@@ -193,3 +197,4 @@ private boolean walletIsOpen() throws KeychainAccessException {
}
}
+
diff --git a/src/main/java/org/cryptomator/linux/keychain/SecretServiceKeychainAccess.java b/src/main/java/org/cryptomator/linux/keychain/SecretServiceKeychainAccess.java
new file mode 100644
index 0000000..f230b6e
--- /dev/null
+++ b/src/main/java/org/cryptomator/linux/keychain/SecretServiceKeychainAccess.java
@@ -0,0 +1,169 @@
+package org.cryptomator.linux.keychain;
+
+import org.cryptomator.integrations.common.DisplayName;
+import org.cryptomator.integrations.common.OperatingSystem;
+import org.cryptomator.integrations.common.Priority;
+import org.cryptomator.integrations.keychain.KeychainAccessException;
+import org.cryptomator.integrations.keychain.KeychainAccessProvider;
+import org.freedesktop.dbus.DBusPath;
+import org.purejava.secret.api.Collection;
+import org.purejava.secret.api.EncryptedSession;
+import org.purejava.secret.api.Item;
+import org.purejava.secret.api.Static;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+@Priority(1100)
+@OperatingSystem(OperatingSystem.Value.LINUX)
+@DisplayName("Secret Service")
+public class SecretServiceKeychainAccess implements KeychainAccessProvider {
+
+ private static final Logger LOG = LoggerFactory.getLogger(SecretServiceKeychainAccess.class);
+ private static final String LABEL_FOR_SECRET_IN_KEYRING = "Cryptomator";
+ private static final String ID_KEY = "Vault";
+ private static final String NAME_KEY = "Name";
+ private final EncryptedSession session = new EncryptedSession();
+ private final Collection collection = new Collection(new DBusPath(Static.DBusPath.DEFAULT_COLLECTION));
+
+ public SecretServiceKeychainAccess() {
+ session.getService().addCollectionChangedHandler(collection -> LOG.debug("Collection {} changed", collection.getPath()));
+ session.getService().addCollectionCreatedHandler(collection -> LOG.debug("Collection {} created", collection.getPath()));
+ session.getService().addCollectionDeletedHandler(collection -> LOG.debug("Collection {} deleted", collection.getPath()));
+ var getAlias = session.getService().readAlias("default");
+ if (getAlias.isSuccess() && "/".equals(getAlias.value().getPath())) {
+ // default alias is not set; set it to the login keyring
+ session.getService().setAlias("default", new DBusPath(Static.DBusPath.LOGIN_COLLECTION));
+ }
+ collection.addItemChangedHandler(item -> LOG.debug("Item {} changed", item.getPath()));
+ collection.addItemCreatedHandler(item -> LOG.debug("Item {} created", item.getPath()));
+ collection.addItemDeletedHandler(item -> LOG.debug("Item {} deleted", item.getPath()));
+
+ }
+
+ @Override
+ public void storePassphrase(String key, String displayName, CharSequence passphrase) throws KeychainAccessException {
+ try {
+ var call = collection.searchItems(withKey(key));
+ if (call.isSuccess()) {
+ if (call.value().isEmpty()) {
+ List lockable = new ArrayList<>();
+ lockable.add(new DBusPath(collection.getDBusPath()));
+ session.getService().unlock(lockable);
+ var itemProps = Item.createProperties(LABEL_FOR_SECRET_IN_KEYRING, withKeyAndName(key, displayName));
+ var secret = session.encrypt(passphrase);
+ var created = collection.createItem(itemProps, secret, false);
+ if (!created.isSuccess()) {
+ throw new KeychainAccessException("Storing password failed", created.error());
+ }
+ } else {
+ changePassphrase(key, displayName, passphrase);
+ }
+ } else {
+ throw new KeychainAccessException("Storing password failed", call.error());
+ }
+ } catch (Exception e) {
+ throw new KeychainAccessException("Storing password failed.", e);
+ }
+ }
+
+ @Override
+ public char[] loadPassphrase(String key) throws KeychainAccessException {
+ try {
+ var call = collection.searchItems(withKey(key));
+ if (call.isSuccess()) {
+ if (!call.value().isEmpty()) {
+ var path = call.value().getFirst();
+ session.getService().ensureUnlocked(path);
+ var secret = new Item(path).getSecret(session.getSession());
+ return session.decrypt(secret);
+ } else {
+ return null;
+ }
+ } else {
+ throw new KeychainAccessException("Loading password failed", call.error());
+ }
+ } catch (Exception e) {
+ throw new KeychainAccessException("Loading password failed.", e);
+ }
+ }
+
+ @Override
+ public void deletePassphrase(String key) throws KeychainAccessException {
+ try {
+ var call = collection.searchItems(withKey(key));
+ if (call.isSuccess()) {
+ if (!call.value().isEmpty()) {
+ var path = call.value().getFirst();
+ session.getService().ensureUnlocked(path);
+ var item = new Item(path);
+ var deleted = item.delete();
+ if (!deleted.isSuccess()) {
+ throw new KeychainAccessException("Deleting password failed", deleted.error());
+ }
+ } else {
+ LOG.debug("Deleting entry with {}={} failed: No such item found", ID_KEY, key);
+ }
+ } else {
+ throw new KeychainAccessException("Deleting password failed", call.error());
+ }
+ } catch (Exception e) {
+ throw new KeychainAccessException("Deleting password failed", e);
+ }
+ }
+
+ @Override
+ public void changePassphrase(String key, String displayName, CharSequence passphrase) throws KeychainAccessException {
+ try {
+ var call = collection.searchItems(withKey(key));
+ if (call.isSuccess()) {
+ if (!call.value().isEmpty()) {
+ session.getService().ensureUnlocked(call.value().getFirst());
+ var secret = session.encrypt(passphrase);
+ var itemProps = Item.createProperties(LABEL_FOR_SECRET_IN_KEYRING, withKeyAndName(key, displayName));
+ var updated = collection.createItem(itemProps, secret, true);
+ if (!updated.isSuccess()) {
+ throw new KeychainAccessException("Updating password failed", updated.error());
+ }
+ } else {
+ var msg = "Vault " + key + " not found, updating failed";
+ throw new KeychainAccessException(msg);
+ }
+ } else {
+ throw new KeychainAccessException("Updating password failed", call.error());
+ }
+ } catch (Exception e) {
+ throw new KeychainAccessException("Updating password failed", e);
+ }
+ }
+
+ @Override
+ public boolean isSupported() {
+ return session.setupEncryptedSession() &&
+ session.getService().hasDefaultCollection();
+ }
+
+ @Override
+ public boolean isLocked() {
+ var call = collection.isLocked();
+ return !call.isSuccess() || call.value();
+ }
+
+ private Map withKey(String key) {
+ if (key == null) {
+ throw new IllegalArgumentException("Arguments must not be null");
+ }
+ return Map.of(ID_KEY, key);
+ }
+
+ private Map withKeyAndName(String key, String name) {
+ if (key == null) {
+ throw new IllegalArgumentException("Arguments must not be null");
+ }
+ return Map.of(ID_KEY, key, NAME_KEY, Objects.requireNonNullElse(name, ""));
+ }
+}
diff --git a/src/main/resources/META-INF/services/org.cryptomator.integrations.keychain.KeychainAccessProvider b/src/main/resources/META-INF/services/org.cryptomator.integrations.keychain.KeychainAccessProvider
index e68909b..3b9354d 100644
--- a/src/main/resources/META-INF/services/org.cryptomator.integrations.keychain.KeychainAccessProvider
+++ b/src/main/resources/META-INF/services/org.cryptomator.integrations.keychain.KeychainAccessProvider
@@ -1,2 +1,3 @@
+org.cryptomator.linux.keychain.SecretServiceKeychainAccess
org.cryptomator.linux.keychain.KDEWalletKeychainAccess
org.cryptomator.linux.keychain.GnomeKeyringKeychainAccess
\ No newline at end of file
diff --git a/src/test/java/org/cryptomator/linux/keychain/GnomeKeyringKeychainAccessTest.java b/src/test/java/org/cryptomator/linux/keychain/GnomeKeyringKeychainAccessTest.java
index 93aa7e0..ebf1df2 100644
--- a/src/test/java/org/cryptomator/linux/keychain/GnomeKeyringKeychainAccessTest.java
+++ b/src/test/java/org/cryptomator/linux/keychain/GnomeKeyringKeychainAccessTest.java
@@ -87,4 +87,4 @@ public static boolean gnomeKeyringAvailableAndUnlocked() {
}
}
-}
\ No newline at end of file
+}
diff --git a/src/test/java/org/cryptomator/linux/keychain/SecretServiceKeychainAccessTest.java b/src/test/java/org/cryptomator/linux/keychain/SecretServiceKeychainAccessTest.java
new file mode 100644
index 0000000..a860e00
--- /dev/null
+++ b/src/test/java/org/cryptomator/linux/keychain/SecretServiceKeychainAccessTest.java
@@ -0,0 +1,91 @@
+package org.cryptomator.linux.keychain;
+
+import org.cryptomator.integrations.keychain.KeychainAccessException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.junit.jupiter.api.condition.EnabledIf;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Unit tests for Secret Service access via Dbus.
+ */
+@EnabledIfEnvironmentVariable(named = "DBUS_SESSION_BUS_ADDRESS", matches = ".*")
+public class SecretServiceKeychainAccessTest {
+
+ private static boolean isInstalled;
+
+ @BeforeAll
+ public static void checkSystemAndSetup() throws IOException {
+ ProcessBuilder dbusSend = new ProcessBuilder("dbus-send", "--print-reply", "--dest=org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus.ListNames");
+ ProcessBuilder grep = new ProcessBuilder("grep", "-q", "org.freedesktop.secrets");
+ try {
+ Process end = ProcessBuilder.startPipeline(List.of(dbusSend, grep)).get(1);
+ if (end.waitFor(1000, TimeUnit.MILLISECONDS)) {
+ isInstalled = end.exitValue() == 0;
+ } else {
+ isInstalled = false;
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ @Test
+ public void testIsSupported() {
+ var service = new SecretServiceKeychainAccess();
+ Assertions.assertEquals(isInstalled, service.isSupported());
+ }
+
+ @Nested
+ @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+ @EnabledIf("serviceAvailableAndUnlocked")
+ class FunctionalTests {
+
+ static final String KEY_ID = "cryptomator-test-" + UUID.randomUUID();
+ final static SecretServiceKeychainAccess KEYRING = new SecretServiceKeychainAccess();
+
+ @Test
+ @Order(1)
+ public void testStore() throws KeychainAccessException {
+ KEYRING.isSupported(); // ensure encrypted session
+ KEYRING.storePassphrase(KEY_ID, "cryptomator-test", "p0ssw0rd");
+ }
+
+ @Test
+ @Order(2)
+ public void testLoad() throws KeychainAccessException {
+ var passphrase = KEYRING.loadPassphrase(KEY_ID);
+ Assertions.assertNotNull(passphrase);
+ Assertions.assertEquals("p0ssw0rd", String.copyValueOf(passphrase));
+ }
+
+ @Test
+ @Order(3)
+ public void testDelete() throws KeychainAccessException {
+ KEYRING.deletePassphrase(KEY_ID);
+ }
+
+ @Test
+ @Order(4)
+ public void testLoadNotExisting() throws KeychainAccessException {
+ var result = KEYRING.loadPassphrase(KEY_ID);
+ Assertions.assertNull(result);
+ }
+
+ public static boolean serviceAvailableAndUnlocked() {
+ var service = new SecretServiceKeychainAccess();
+ return service.isSupported() && !service.isLocked();
+ }
+ }
+
+}