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(); + } + } + +}