diff --git a/pom.xml b/pom.xml
index b55dfa6..cd491c3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -43,6 +43,7 @@
1.7.0
2.0.1-alpha
1.4.0
+ 1.0.1
2.0.17
1.4.2
@@ -82,6 +83,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 c728d22..7384e5d 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -4,8 +4,9 @@
import org.cryptomator.integrations.revealpath.RevealPathService;
import org.cryptomator.integrations.tray.TrayMenuController;
import org.cryptomator.linux.autostart.FreedesktopAutoStartService;
-import org.cryptomator.linux.keychain.KDEWalletKeychainAccess;
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;
@@ -18,10 +19,11 @@
requires org.purejava.appindicator;
requires org.purejava.kwallet;
requires de.swiesend.secretservice;
+ requires org.purejava.secret;
requires java.xml;
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/KDEWalletKeychainAccess.java b/src/main/java/org/cryptomator/linux/keychain/KDEWalletKeychainAccess.java
index 5db6fa8..3ce5433 100644
--- a/src/main/java/org/cryptomator/linux/keychain/KDEWalletKeychainAccess.java
+++ b/src/main/java/org/cryptomator/linux/keychain/KDEWalletKeychainAccess.java
@@ -193,3 +193,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..927b83f
--- /dev/null
+++ b/src/main/java/org/cryptomator/linux/keychain/SecretServiceKeychainAccess.java
@@ -0,0 +1,199 @@
+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 javax.crypto.BadPaddingException;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.NoSuchPaddingException;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Priority(1100)
+@OperatingSystem(OperatingSystem.Value.LINUX)
+@DisplayName("Secret Service")
+public class SecretServiceKeychainAccess implements KeychainAccessProvider {
+
+ private static final Logger LOG = LoggerFactory.getLogger(SecretServiceKeychainAccess.class);
+ 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()));
+
+ migrateKDEWalletEntries();
+ }
+
+ @Override
+ public void storePassphrase(String key, String displayName, CharSequence passphrase) throws KeychainAccessException {
+ try {
+ var call = collection.searchItems(createAttributes(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(displayName, createAttributes(key));
+ 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(createAttributes(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(createAttributes(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 {
+ var msg = "Vault " + key + " not found, deleting failed";
+ throw new KeychainAccessException(msg);
+ }
+ } 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(createAttributes(key));
+ if (call.isSuccess()) {
+ if (!call.value().isEmpty()) {
+ session.getService().ensureUnlocked(call.value().getFirst());
+ var secret = session.encrypt(passphrase);
+ var itemProps = Item.createProperties(displayName, createAttributes(key));
+ 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 createAttributes(String key) {
+ return Map.of("Vault", key);
+ }
+
+ private void migrateKDEWalletEntries() {
+ session.setupEncryptedSession();
+ var getItems = collection.getItems();
+ if (getItems.isSuccess() && !getItems.value().isEmpty()) {
+ for (DBusPath i : getItems.value()) {
+ session.getService().ensureUnlocked(i);
+ var attribs = new Item(i).getAttributes();
+ if (attribs.isSuccess() &&
+ attribs.value().containsKey("server") &&
+ attribs.value().containsKey("user") &&
+ attribs.value().get("server").equals("Cryptomator")) {
+
+ session.getService().ensureUnlocked(i);
+ var item = new Item(i);
+ var secret = item.getSecret(session.getSession());
+ Map newAttribs = new HashMap<>(attribs.value());
+ newAttribs.put("server", "Cryptomator - already migrated");
+ var label = item.getLabel().value();
+ var itemProps = Item.createProperties(label, newAttribs);
+ var replace = collection.createItem(itemProps, secret, true);
+ assert replace.isSuccess() : "Replacing migrated item failed";
+ item.delete();
+ try {
+ storePassphrase(attribs.value().get("user"), "Cryptomator", new String(session.decrypt(secret)));
+ LOG.info("Successfully migrated password for vault {}", attribs.value().get("user"));
+ } catch (KeychainAccessException | NoSuchPaddingException | NoSuchAlgorithmException |
+ InvalidAlgorithmParameterException | InvalidKeyException | BadPaddingException |
+ IllegalBlockSizeException e) {
+ LOG.error("Migrating entry {} for vault {} failed", i.getPath(), attribs.value().get("user"));
+ }
+ }
+ }
+ }
+ }
+}
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..b07b869
--- /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 = "DISPLAY", 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();
+ }
+ }
+
+}