-
Notifications
You must be signed in to change notification settings - Fork 6
New secret service #125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
New secret service #125
Changes from all commits
a8a57c2
13e3132
5163d8b
9f227d0
c22f813
161ea15
b9c6eb1
1c54f97
17a4f69
ca6b4d8
857c812
3067f3b
163ad9a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -193,3 +193,4 @@ private boolean walletIsOpen() throws KeychainAccessException { | |
|
|
||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<DBusPath> 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); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
purejava marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
|
|
||||||||||||||||||
| @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<String, String> createAttributes(String key) { | ||||||||||||||||||
| return Map.of("Vault", key); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| private void migrateKDEWalletEntries() { | ||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I remember that we did a migration between keychains for macOS. It was done in the main project, see https://github.com/cryptomator/cryptomator/blob/a7b8541912adce80744eed1640abd98991c32420/src/main/java/org/cryptomator/ui/preferences/GeneralPreferencesController.java#L103-L121. Or is there a reason that the migration cannot be done on a higher level? |
||||||||||||||||||
| 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<String, String> 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"; | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replace assertion with proper error handling. Using 🔎 Proposed fix var itemProps = Item.createProperties(label, newAttribs);
var replace = collection.createItem(itemProps, secret, true);
- assert replace.isSuccess() : "Replacing migrated item failed";
+ if (!replace.isSuccess()) {
+ LOG.error("Replacing migrated item {} failed: {}", i.getPath(), replace.error());
+ continue;
+ }
item.delete();📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @purejava What is the reason not checking the success status of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yes, this should be checked, there is no reason to omit such a check. But, probably we do not need I wasn't aware of this feature and I am about to test, whether |
||||||||||||||||||
| 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")); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,3 @@ | ||
| org.cryptomator.linux.keychain.SecretServiceKeychainAccess | ||
| org.cryptomator.linux.keychain.KDEWalletKeychainAccess | ||
| org.cryptomator.linux.keychain.GnomeKeyringKeychainAccess |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -87,4 +87,4 @@ public static boolean gnomeKeyringAvailableAndUnlocked() { | |
| } | ||
| } | ||
|
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = ".*") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As the bot pointed out: Do we require to disable the tests if no DISPLAY is available?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @infeo, I understand the bot to mean, that DISPLAY is not set for Wayland and the guard only works for x server sessions. And I understand your question to mean, whether to disable the guard, when DISLPLAY is not set? To my understanding, the guard already checks that: when DISPLAY contains anything, so an x server is available, the test is run. The guard checks, whether we are on a system with a display and not on CI, so in the end whether DBus is available, right? So my suggestion is to use
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The bot pointed out, that the "is-supported" method (and all other methods), do not depend on the DISPLAY variable. I don't know Linux good, but to my understanding DBus is not tied to a graphical user interface. It is independent of it. Hence, yes we should check wether dbus is available on the system.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are right. DBus is independent of a graphical user interface. I wasn't aware of that. Nevertheless, the change mentioned above checks exactly what is needed here. I'll commit it. |
||
| 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(); | ||
| } | ||
| } | ||
|
|
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.