Skip to content

Commit e98c9c4

Browse files
purejavainfeo
andauthored
New secret service KeychainAccess (#125)
Also deprecate GnomeKeyringKeychainAccess, KDEWalletKeychainAccess --------- Co-authored-by: Armin Schrenk <armin.schrenk@skymatic.de>
1 parent f34007f commit e98c9c4

8 files changed

Lines changed: 280 additions & 2 deletions

File tree

pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
<jackson.version>2.20.1</jackson.version>
4545
<secret-service.version>2.0.1-alpha</secret-service.version>
4646
<kdewallet.version>1.4.0</kdewallet.version>
47+
<secret-service-02.version>1.1.0</secret-service-02.version>
4748
<flatpakupdateportal.version>1.1.1</flatpakupdateportal.version>
4849
<appindicator.version>1.4.2</appindicator.version>
4950

@@ -103,6 +104,11 @@
103104
<artifactId>kdewallet</artifactId>
104105
<version>${kdewallet.version}</version>
105106
</dependency>
107+
<dependency>
108+
<groupId>org.purejava</groupId>
109+
<artifactId>secret-service</artifactId>
110+
<version>${secret-service-02.version}</version>
111+
</dependency>
106112
<!-- Java bindings for appindicator -->
107113
<dependency>
108114
<groupId>org.purejava</groupId>

src/main/java/module-info.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import org.cryptomator.linux.autostart.FreedesktopAutoStartService;
88
import org.cryptomator.linux.keychain.GnomeKeyringKeychainAccess;
99
import org.cryptomator.linux.keychain.KDEWalletKeychainAccess;
10+
import org.cryptomator.linux.keychain.SecretServiceKeychainAccess;
1011
import org.cryptomator.linux.quickaccess.DolphinPlaces;
1112
import org.cryptomator.linux.quickaccess.NautilusBookmarks;
1213
import org.cryptomator.linux.revealpath.DBusSendRevealPathService;
@@ -21,12 +22,13 @@
2122
requires org.purejava.kwallet;
2223
requires org.purejava.portal;
2324
requires de.swiesend.secretservice;
25+
requires org.purejava.secret;
2426
requires java.xml;
2527
requires java.net.http;
2628
requires com.fasterxml.jackson.databind;
2729

2830
provides AutoStartProvider with FreedesktopAutoStartService;
29-
provides KeychainAccessProvider with GnomeKeyringKeychainAccess, KDEWalletKeychainAccess;
31+
provides KeychainAccessProvider with SecretServiceKeychainAccess, GnomeKeyringKeychainAccess, KDEWalletKeychainAccess;
3032
provides RevealPathService with DBusSendRevealPathService;
3133
provides TrayMenuController with AppindicatorTrayMenuController;
3234
provides QuickAccessService with NautilusBookmarks, DolphinPlaces;

src/main/java/org/cryptomator/linux/keychain/GnomeKeyringKeychainAccess.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@
1313
import java.util.List;
1414
import java.util.Map;
1515

16+
/**
17+
* @deprecated Cryptomator has Secret Service as the successor of KDE Wallet and GNOME keyring as a keychain backend since version 1.19.0
18+
*/
1619
@Priority(900)
1720
@OperatingSystem(OperatingSystem.Value.LINUX)
1821
@DisplayName("GNOME Keyring")
22+
@Deprecated(since = "1.7.0")
1923
public class GnomeKeyringKeychainAccess implements KeychainAccessProvider {
2024

2125
private static final Logger LOG = LoggerFactory.getLogger(GnomeKeyringKeychainAccess.class);

src/main/java/org/cryptomator/linux/keychain/KDEWalletKeychainAccess.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,13 @@
1919

2020
import java.util.Optional;
2121

22+
/**
23+
* @deprecated Cryptomator has Secret Service as the successor of KDE Wallet and GNOME keyring as a keychain backend since version 1.19.0
24+
*/
2225
@Priority(900)
2326
@OperatingSystem(OperatingSystem.Value.LINUX)
2427
@DisplayName("KDE Wallet")
28+
@Deprecated(since = "1.7.0")
2529
public class KDEWalletKeychainAccess implements KeychainAccessProvider {
2630

2731
private static final Logger LOG = LoggerFactory.getLogger(KDEWalletKeychainAccess.class);
@@ -193,3 +197,4 @@ private boolean walletIsOpen() throws KeychainAccessException {
193197

194198
}
195199
}
200+
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package org.cryptomator.linux.keychain;
2+
3+
import org.cryptomator.integrations.common.DisplayName;
4+
import org.cryptomator.integrations.common.OperatingSystem;
5+
import org.cryptomator.integrations.common.Priority;
6+
import org.cryptomator.integrations.keychain.KeychainAccessException;
7+
import org.cryptomator.integrations.keychain.KeychainAccessProvider;
8+
import org.freedesktop.dbus.DBusPath;
9+
import org.purejava.secret.api.Collection;
10+
import org.purejava.secret.api.EncryptedSession;
11+
import org.purejava.secret.api.Item;
12+
import org.purejava.secret.api.Static;
13+
import org.slf4j.Logger;
14+
import org.slf4j.LoggerFactory;
15+
16+
import java.util.ArrayList;
17+
import java.util.List;
18+
import java.util.Map;
19+
import java.util.Objects;
20+
21+
@Priority(1100)
22+
@OperatingSystem(OperatingSystem.Value.LINUX)
23+
@DisplayName("Secret Service")
24+
public class SecretServiceKeychainAccess implements KeychainAccessProvider {
25+
26+
private static final Logger LOG = LoggerFactory.getLogger(SecretServiceKeychainAccess.class);
27+
private static final String LABEL_FOR_SECRET_IN_KEYRING = "Cryptomator";
28+
private static final String ID_KEY = "Vault";
29+
private static final String NAME_KEY = "Name";
30+
private final EncryptedSession session = new EncryptedSession();
31+
private final Collection collection = new Collection(new DBusPath(Static.DBusPath.DEFAULT_COLLECTION));
32+
33+
public SecretServiceKeychainAccess() {
34+
session.getService().addCollectionChangedHandler(collection -> LOG.debug("Collection {} changed", collection.getPath()));
35+
session.getService().addCollectionCreatedHandler(collection -> LOG.debug("Collection {} created", collection.getPath()));
36+
session.getService().addCollectionDeletedHandler(collection -> LOG.debug("Collection {} deleted", collection.getPath()));
37+
var getAlias = session.getService().readAlias("default");
38+
if (getAlias.isSuccess() && "/".equals(getAlias.value().getPath())) {
39+
// default alias is not set; set it to the login keyring
40+
session.getService().setAlias("default", new DBusPath(Static.DBusPath.LOGIN_COLLECTION));
41+
}
42+
collection.addItemChangedHandler(item -> LOG.debug("Item {} changed", item.getPath()));
43+
collection.addItemCreatedHandler(item -> LOG.debug("Item {} created", item.getPath()));
44+
collection.addItemDeletedHandler(item -> LOG.debug("Item {} deleted", item.getPath()));
45+
46+
}
47+
48+
@Override
49+
public void storePassphrase(String key, String displayName, CharSequence passphrase) throws KeychainAccessException {
50+
try {
51+
var call = collection.searchItems(withKey(key));
52+
if (call.isSuccess()) {
53+
if (call.value().isEmpty()) {
54+
List<DBusPath> lockable = new ArrayList<>();
55+
lockable.add(new DBusPath(collection.getDBusPath()));
56+
session.getService().unlock(lockable);
57+
var itemProps = Item.createProperties(LABEL_FOR_SECRET_IN_KEYRING, withKeyAndName(key, displayName));
58+
var secret = session.encrypt(passphrase);
59+
var created = collection.createItem(itemProps, secret, false);
60+
if (!created.isSuccess()) {
61+
throw new KeychainAccessException("Storing password failed", created.error());
62+
}
63+
} else {
64+
changePassphrase(key, displayName, passphrase);
65+
}
66+
} else {
67+
throw new KeychainAccessException("Storing password failed", call.error());
68+
}
69+
} catch (Exception e) {
70+
throw new KeychainAccessException("Storing password failed.", e);
71+
}
72+
}
73+
74+
@Override
75+
public char[] loadPassphrase(String key) throws KeychainAccessException {
76+
try {
77+
var call = collection.searchItems(withKey(key));
78+
if (call.isSuccess()) {
79+
if (!call.value().isEmpty()) {
80+
var path = call.value().getFirst();
81+
session.getService().ensureUnlocked(path);
82+
var secret = new Item(path).getSecret(session.getSession());
83+
return session.decrypt(secret);
84+
} else {
85+
return null;
86+
}
87+
} else {
88+
throw new KeychainAccessException("Loading password failed", call.error());
89+
}
90+
} catch (Exception e) {
91+
throw new KeychainAccessException("Loading password failed.", e);
92+
}
93+
}
94+
95+
@Override
96+
public void deletePassphrase(String key) throws KeychainAccessException {
97+
try {
98+
var call = collection.searchItems(withKey(key));
99+
if (call.isSuccess()) {
100+
if (!call.value().isEmpty()) {
101+
var path = call.value().getFirst();
102+
session.getService().ensureUnlocked(path);
103+
var item = new Item(path);
104+
var deleted = item.delete();
105+
if (!deleted.isSuccess()) {
106+
throw new KeychainAccessException("Deleting password failed", deleted.error());
107+
}
108+
} else {
109+
LOG.debug("Deleting entry with {}={} failed: No such item found", ID_KEY, key);
110+
}
111+
} else {
112+
throw new KeychainAccessException("Deleting password failed", call.error());
113+
}
114+
} catch (Exception e) {
115+
throw new KeychainAccessException("Deleting password failed", e);
116+
}
117+
}
118+
119+
@Override
120+
public void changePassphrase(String key, String displayName, CharSequence passphrase) throws KeychainAccessException {
121+
try {
122+
var call = collection.searchItems(withKey(key));
123+
if (call.isSuccess()) {
124+
if (!call.value().isEmpty()) {
125+
session.getService().ensureUnlocked(call.value().getFirst());
126+
var secret = session.encrypt(passphrase);
127+
var itemProps = Item.createProperties(LABEL_FOR_SECRET_IN_KEYRING, withKeyAndName(key, displayName));
128+
var updated = collection.createItem(itemProps, secret, true);
129+
if (!updated.isSuccess()) {
130+
throw new KeychainAccessException("Updating password failed", updated.error());
131+
}
132+
} else {
133+
var msg = "Vault " + key + " not found, updating failed";
134+
throw new KeychainAccessException(msg);
135+
}
136+
} else {
137+
throw new KeychainAccessException("Updating password failed", call.error());
138+
}
139+
} catch (Exception e) {
140+
throw new KeychainAccessException("Updating password failed", e);
141+
}
142+
}
143+
144+
@Override
145+
public boolean isSupported() {
146+
return session.setupEncryptedSession() &&
147+
session.getService().hasDefaultCollection();
148+
}
149+
150+
@Override
151+
public boolean isLocked() {
152+
var call = collection.isLocked();
153+
return !call.isSuccess() || call.value();
154+
}
155+
156+
private Map<String, String> withKey(String key) {
157+
if (key == null) {
158+
throw new IllegalArgumentException("Arguments must not be null");
159+
}
160+
return Map.of(ID_KEY, key);
161+
}
162+
163+
private Map<String, String> withKeyAndName(String key, String name) {
164+
if (key == null) {
165+
throw new IllegalArgumentException("Arguments must not be null");
166+
}
167+
return Map.of(ID_KEY, key, NAME_KEY, Objects.requireNonNullElse(name, ""));
168+
}
169+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
org.cryptomator.linux.keychain.SecretServiceKeychainAccess
12
org.cryptomator.linux.keychain.KDEWalletKeychainAccess
23
org.cryptomator.linux.keychain.GnomeKeyringKeychainAccess

src/test/java/org/cryptomator/linux/keychain/GnomeKeyringKeychainAccessTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,4 @@ public static boolean gnomeKeyringAvailableAndUnlocked() {
8787
}
8888
}
8989

90-
}
90+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package org.cryptomator.linux.keychain;
2+
3+
import org.cryptomator.integrations.keychain.KeychainAccessException;
4+
import org.junit.jupiter.api.Assertions;
5+
import org.junit.jupiter.api.BeforeAll;
6+
import org.junit.jupiter.api.MethodOrderer;
7+
import org.junit.jupiter.api.Nested;
8+
import org.junit.jupiter.api.Order;
9+
import org.junit.jupiter.api.Test;
10+
import org.junit.jupiter.api.TestMethodOrder;
11+
import org.junit.jupiter.api.condition.EnabledIf;
12+
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
13+
14+
import java.io.IOException;
15+
import java.util.List;
16+
import java.util.UUID;
17+
import java.util.concurrent.TimeUnit;
18+
19+
/**
20+
* Unit tests for Secret Service access via Dbus.
21+
*/
22+
@EnabledIfEnvironmentVariable(named = "DBUS_SESSION_BUS_ADDRESS", matches = ".*")
23+
public class SecretServiceKeychainAccessTest {
24+
25+
private static boolean isInstalled;
26+
27+
@BeforeAll
28+
public static void checkSystemAndSetup() throws IOException {
29+
ProcessBuilder dbusSend = new ProcessBuilder("dbus-send", "--print-reply", "--dest=org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus.ListNames");
30+
ProcessBuilder grep = new ProcessBuilder("grep", "-q", "org.freedesktop.secrets");
31+
try {
32+
Process end = ProcessBuilder.startPipeline(List.of(dbusSend, grep)).get(1);
33+
if (end.waitFor(1000, TimeUnit.MILLISECONDS)) {
34+
isInstalled = end.exitValue() == 0;
35+
} else {
36+
isInstalled = false;
37+
}
38+
} catch (InterruptedException e) {
39+
Thread.currentThread().interrupt();
40+
}
41+
}
42+
43+
@Test
44+
public void testIsSupported() {
45+
var service = new SecretServiceKeychainAccess();
46+
Assertions.assertEquals(isInstalled, service.isSupported());
47+
}
48+
49+
@Nested
50+
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
51+
@EnabledIf("serviceAvailableAndUnlocked")
52+
class FunctionalTests {
53+
54+
static final String KEY_ID = "cryptomator-test-" + UUID.randomUUID();
55+
final static SecretServiceKeychainAccess KEYRING = new SecretServiceKeychainAccess();
56+
57+
@Test
58+
@Order(1)
59+
public void testStore() throws KeychainAccessException {
60+
KEYRING.isSupported(); // ensure encrypted session
61+
KEYRING.storePassphrase(KEY_ID, "cryptomator-test", "p0ssw0rd");
62+
}
63+
64+
@Test
65+
@Order(2)
66+
public void testLoad() throws KeychainAccessException {
67+
var passphrase = KEYRING.loadPassphrase(KEY_ID);
68+
Assertions.assertNotNull(passphrase);
69+
Assertions.assertEquals("p0ssw0rd", String.copyValueOf(passphrase));
70+
}
71+
72+
@Test
73+
@Order(3)
74+
public void testDelete() throws KeychainAccessException {
75+
KEYRING.deletePassphrase(KEY_ID);
76+
}
77+
78+
@Test
79+
@Order(4)
80+
public void testLoadNotExisting() throws KeychainAccessException {
81+
var result = KEYRING.loadPassphrase(KEY_ID);
82+
Assertions.assertNull(result);
83+
}
84+
85+
public static boolean serviceAvailableAndUnlocked() {
86+
var service = new SecretServiceKeychainAccess();
87+
return service.isSupported() && !service.isLocked();
88+
}
89+
}
90+
91+
}

0 commit comments

Comments
 (0)