diff --git a/src/main/java/org/cryptomator/launcher/AppLaunchEvent.java b/src/main/java/org/cryptomator/launcher/AppLaunchEvent.java
index b7ee72f8f..657f7b30c 100644
--- a/src/main/java/org/cryptomator/launcher/AppLaunchEvent.java
+++ b/src/main/java/org/cryptomator/launcher/AppLaunchEvent.java
@@ -8,9 +8,9 @@ package org.cryptomator.launcher;
*
* - {@link RevealRunningEvent} - reveal the already-running app,
* - {@link OpenFileEvent} - open one or more paths,
- * - {@link VaultCreationEvent} - create a vault from a deeplink.
+ * - {@link OpenHubVaultEvent} - open a Hub vault from a deeplink.
*
*/
-public sealed interface AppLaunchEvent permits RevealRunningEvent, OpenFileEvent, VaultCreationEvent {
+public sealed interface AppLaunchEvent permits RevealRunningEvent, OpenFileEvent, OpenHubVaultEvent {
}
diff --git a/src/main/java/org/cryptomator/launcher/OpenHubVaultEvent.java b/src/main/java/org/cryptomator/launcher/OpenHubVaultEvent.java
new file mode 100644
index 000000000..f33580d9c
--- /dev/null
+++ b/src/main/java/org/cryptomator/launcher/OpenHubVaultEvent.java
@@ -0,0 +1,172 @@
+package org.cryptomator.launcher;
+
+import org.cryptomator.cryptofs.VaultConfig;
+import org.cryptomator.cryptofs.VaultConfigLoadException;
+import org.cryptomator.ui.keyloading.hub.HubConfig;
+import org.cryptomator.ui.keyloading.hub.HubKeyLoadingStrategy;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.URI;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Requests opening a Hub vault from an {@code org.cryptomator://vault/open#vaultConfig=…} deeplink.
+ *
+ * The single parameter is the vault's {@code vault.cryptomator}, a compact JWS embedded verbatim - a compact JWS
+ * consists only of characters that are unreserved in a URI fragment, so it needs no further encoding. It is carried in
+ * the fragment part so that it stays out of the logs of any web page that mints such a link.
+ *
+ * The config is read unverified: its signature is keyed on the masterkey, which is only obtainable from Hub
+ * later on. Everything consumed before that point is therefore validated here rather than trusted.
+ *
+ * @param vaultConfig the decoded, unverified vault config
+ * @param vaultId the vault's id within its Hub instance, taken from the config's key id
+ */
+public record OpenHubVaultEvent(VaultConfig.UnverifiedVaultConfig vaultConfig, UUID vaultId) implements AppLaunchEvent {
+
+ private static final Logger LOG = LoggerFactory.getLogger(OpenHubVaultEvent.class);
+ private static final String SCHEME = "org.cryptomator";
+ private static final String HOST = "vault";
+ private static final String PATH = "/open";
+ private static final String PARAM_VAULT_CONFIG = "vaultConfig";
+ private static final String HUB_HEADER = "hub";
+ private static final int MAX_CONFIG_LENGTH = 8192; //real Hub vault config is ~1KB leaving some room for extensions
+
+ /**
+ * Attempts to interpret the given URI as an {@code org.cryptomator://vault/open#vaultConfig=…} deeplink.
+ *
+ * @param uri the deeplink URI
+ * @return the parsed event, or an empty optional if the URI's scheme, host or path do not identify a vault-open
+ * deeplink
+ * @throws IllegalArgumentException if the URI identifies a vault-open deeplink, but the config is missing, too
+ * large, not decodable, or does not describe a Hub vault
+ */
+ public static Optional tryParse(URI uri) {
+ if (!SCHEME.equalsIgnoreCase(uri.getScheme()) || !HOST.equalsIgnoreCase(uri.getHost()) || !PATH.equals(uri.getPath())) {
+ return Optional.empty();
+ }
+ var params = parseParams(uri.getRawFragment());
+
+ var token = params.get(PARAM_VAULT_CONFIG);
+ if (token == null || token.isBlank()) {
+ throw new IllegalArgumentException("Missing required fragment parameter '" + PARAM_VAULT_CONFIG + "'.");
+ }
+ var vaultConfig = decode(token);
+ requireHubVault(vaultConfig);
+ var vaultId = extractVaultId(vaultConfig.getKeyId());
+
+ var leftoverParams = params.keySet().stream().filter(k -> !k.equals(PARAM_VAULT_CONFIG)).toList();
+ if (!leftoverParams.isEmpty()) {
+ LOG.debug("Ignoring unknown parameters {}", leftoverParams);
+ }
+
+ return Optional.of(new OpenHubVaultEvent(vaultConfig, vaultId));
+ }
+
+ private static VaultConfig.UnverifiedVaultConfig decode(String token) {
+ // a compact JWS is ASCII, so its character count is its byte count
+ if (token.length() > MAX_CONFIG_LENGTH) {
+ throw new IllegalArgumentException("Fragment parameter '%s' must not exceed %d bytes.".formatted(PARAM_VAULT_CONFIG, MAX_CONFIG_LENGTH));
+ }
+ try {
+ return VaultConfig.decode(token);
+ } catch (VaultConfigLoadException e) {
+ throw new IllegalArgumentException("Fragment parameter '" + PARAM_VAULT_CONFIG + "' is not a decodable vault config.", e);
+ }
+ }
+
+ /**
+ * Ensures the config describes a Hub vault and that the endpoints the app will talk to are usable.
+ */
+ private static void requireHubVault(VaultConfig.UnverifiedVaultConfig vaultConfig) {
+ var keyIdScheme = vaultConfig.getKeyId().getScheme();
+ if (keyIdScheme == null || !keyIdScheme.startsWith(HubKeyLoadingStrategy.SCHEME_PREFIX)) {
+ throw new IllegalArgumentException("Vault config does not describe a Hub vault, but had key id scheme '" + keyIdScheme + "'.");
+ }
+ HubConfig hubConfig;
+ try {
+ hubConfig = vaultConfig.getHeader(HUB_HEADER, HubConfig.class);
+ } catch (RuntimeException e) {
+ throw new IllegalArgumentException("Vault config contains an unreadable '" + HUB_HEADER + "' header.", e);
+ }
+ if (hubConfig == null) {
+ throw new IllegalArgumentException("Vault config contains no '" + HUB_HEADER + "' header.");
+ }
+ URI apiBaseUrl;
+ try {
+ apiBaseUrl = hubConfig.getApiBaseUrl();
+ } catch (RuntimeException e) {
+ throw new IllegalArgumentException("Vault config declares no usable hub api base url.", e);
+ }
+ requireUsableEndpoint("apiBaseUrl", apiBaseUrl);
+ requireUsableEndpoint("authEndpoint", toUri("authEndpoint", hubConfig.authEndpoint));
+ }
+
+ private static URI toUri(String field, String value) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException("Vault config declares no hub " + field + ".");
+ }
+ try {
+ return URI.create(value);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Vault config's hub " + field + " is not a valid url, but was '" + value + "'.", e);
+ }
+ }
+
+ private static void requireUsableEndpoint(String field, URI uri) {
+ if (!uri.isAbsolute() || uri.getHost() == null) {
+ throw new IllegalArgumentException("Vault config's hub " + field + " is not an absolute url with a host, but was '" + uri + "'.");
+ }
+ // Whether an http host is acceptable (it is, for local development) is decided by CheckHostTrustController, the
+ // authority on host trust. Here we only ensure the endpoint is shaped like something that decision can be made on.
+ var scheme = uri.getScheme();
+ if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) {
+ throw new IllegalArgumentException("Vault config's hub " + field + " is neither http nor https, but was '" + uri + "'.");
+ }
+ }
+
+ /**
+ * Reads the vault id from the trailing path segment of a key id such as
+ * {@code hub+https://hub.example.com/api/vaults/}.
+ *
+ * Requiring a UUID matters beyond well-formedness: the id is interpolated into the {@code api/vaults/{vaultId}/…}
+ * request path, so it must not be able to introduce a path segment. Re-serializing the parsed {@link UUID} rather
+ * than passing the raw segment on keeps that guarantee.
+ */
+ private static UUID extractVaultId(URI keyId) {
+ var path = keyId.getPath();
+ if (path == null || path.isEmpty()) {
+ throw new IllegalArgumentException("Vault config's key id contains no vault id, but was '" + keyId + "'.");
+ }
+ var lastSegment = path.substring(path.lastIndexOf('/') + 1);
+ try {
+ return UUID.fromString(lastSegment);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Vault config's key id does not end in a vault id, but was '" + keyId + "'.", e);
+ }
+ }
+
+ private static Map parseParams(String rawParams) {
+ var params = new HashMap();
+ if (rawParams == null || rawParams.isEmpty()) {
+ return params;
+ }
+ for (var pair : rawParams.split("&")) {
+ var idx = pair.indexOf('=');
+ if (idx < 0) {
+ continue;
+ }
+ var key = URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8);
+ var value = URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8);
+ params.put(key, value);
+ }
+ return params;
+ }
+
+}
diff --git a/src/main/java/org/cryptomator/launcher/URIOpenRequestHandler.java b/src/main/java/org/cryptomator/launcher/URIOpenRequestHandler.java
index 57eb41bae..b65cca089 100644
--- a/src/main/java/org/cryptomator/launcher/URIOpenRequestHandler.java
+++ b/src/main/java/org/cryptomator/launcher/URIOpenRequestHandler.java
@@ -22,7 +22,7 @@ public class URIOpenRequestHandler {
* not its concern, or throws {@link IllegalArgumentException} if the URI is its concern but malformed.
*/
private static final List>> DEEPLINK_PARSERS = List.of( //
- VaultCreationEvent::tryParse //
+ OpenHubVaultEvent::tryParse //
);
private final BlockingQueue launchEventQueue;
diff --git a/src/main/java/org/cryptomator/launcher/VaultCreationEvent.java b/src/main/java/org/cryptomator/launcher/VaultCreationEvent.java
deleted file mode 100644
index 39c7ed17f..000000000
--- a/src/main/java/org/cryptomator/launcher/VaultCreationEvent.java
+++ /dev/null
@@ -1,118 +0,0 @@
-package org.cryptomator.launcher;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.net.URI;
-import java.net.URLDecoder;
-import java.nio.charset.StandardCharsets;
-import java.util.Base64;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Optional;
-
-/**
- * Requests creation of a new vault from a {@code cryptomator://vault/create#name=…&template=…} deeplink.
- *
- * The {@code template} is a Base64URL-encoded ZIP archive holding a ready-made (signed) vault. The {@code name} fixes
- * the vault's directory name and is restricted to a single, safe path segment. Both parameters are carried in the URI
- * fragment part.
- *
- * @param name the fixed vault name (a single, safe path segment)
- * @param template the Base64URL-decoded vault template
- */
-public record VaultCreationEvent(String name, byte[] template) implements AppLaunchEvent {
-
- private static final Logger LOG = LoggerFactory.getLogger(VaultCreationEvent.class);
- private static final int MAX_NAME_LENGTH = 256;
- private static final String FILE_SEPARATOR = System.getProperty("file.separator");
- private static final String SCHEME = "cryptomator";
- private static final String HOST = "vault";
- private static final String PATH = "/create";
-
- /**
- * Attempts to interpret the given URI as a {@code cryptomator://vault/create#name=…&template=…} deeplink.
- *
- * @param uri the deeplink URI
- * @return the parsed event, or an empty optional if the URI's scheme, host or path do not identify a
- * vault-creation deeplink
- * @throws IllegalArgumentException if the URI identifies a vault-creation deeplink, but a required parameter is
- * missing, the template is not valid Base64URL, or the name is not a single safe
- * path segment
- */
- public static Optional tryParse(URI uri) {
- if (!SCHEME.equalsIgnoreCase(uri.getScheme()) || !HOST.equalsIgnoreCase(uri.getHost()) || !PATH.equals(uri.getPath())) {
- return Optional.empty();
- }
- var params = parseParams(uri.getRawFragment());
-
- var name = params.get("name");
- if (name == null || name.isBlank()) {
- throw new IllegalArgumentException("Missing required fragment parameter 'name'.");
- }
- validateName(name);
-
- var templateParam = params.get("template");
- if (templateParam == null || templateParam.isEmpty()) {
- throw new IllegalArgumentException("Missing required fragment parameter 'template'.");
- }
- byte[] template;
- try {
- template = Base64.getUrlDecoder().decode(templateParam);
- } catch (IllegalArgumentException e) {
- throw new IllegalArgumentException("Fragment parameter 'template' is not valid Base64URL.", e);
- }
-
- var leftoverParams = params.keySet().stream().filter(k -> !(k.equals("template") || k.equals("name"))).toList();
- if (!leftoverParams.isEmpty()) {
- LOG.debug("Ignoring unknown parameters {}", leftoverParams);
- }
-
- return Optional.of(new VaultCreationEvent(name, template));
- }
-
- //TODO: what does Cryptomator Hub allow in vault names?
- private static void validateName(String name) {
- if (name.codePointCount(0, name.length()) > MAX_NAME_LENGTH) {
- throw new IllegalArgumentException("Fragment parameter 'name' must not exceed " + MAX_NAME_LENGTH + " characters.");
- }
- if (name.contains("/") || name.contains("\\") || name.contains(FILE_SEPARATOR) || name.contains("..") || name.equals(".")) {
- throw new IllegalArgumentException("Fragment parameter 'name' must be a single path segment, but was '" + name + "'.");
- }
- if (!name.equals(name.stripTrailing())) {
- // Windows silently strips these, so the directory would not match the requested name
- throw new IllegalArgumentException("Fragment parameter 'name' must not end with whitespace, but was '" + name + "'.");
- }
- // invisible characters (control chars, bidi overrides such as U+202E, zero-width joiners, ...) let a name
- // render deceptively, e.g. "Rechnunggnp.exe" showing up as "Rechnungexe.png"
- var offendingCodePoint = name.codePoints().filter(VaultCreationEvent::isInvisible).findFirst();
- if (offendingCodePoint.isPresent()) {
- throw new IllegalArgumentException("Fragment parameter 'name' must not contain invisible characters, but contained U+%04X.".formatted(offendingCodePoint.getAsInt()));
- }
- }
-
- private static boolean isInvisible(int codePoint) {
- return switch (Character.getType(codePoint)) {
- case Character.CONTROL, Character.FORMAT, Character.SURROGATE, Character.PRIVATE_USE, Character.UNASSIGNED -> true;
- default -> false;
- };
- }
-
- private static Map parseParams(String rawParams) {
- var params = new HashMap();
- if (rawParams == null || rawParams.isEmpty()) {
- return params;
- }
- for (var pair : rawParams.split("&")) {
- var idx = pair.indexOf('=');
- if (idx < 0) {
- continue;
- }
- var key = URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8);
- var value = URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8);
- params.put(key, value);
- }
- return params;
- }
-
-}
diff --git a/src/main/java/org/cryptomator/ui/fxapp/AppLaunchEventHandler.java b/src/main/java/org/cryptomator/ui/fxapp/AppLaunchEventHandler.java
index ea85e6c3e..546107b7a 100644
--- a/src/main/java/org/cryptomator/ui/fxapp/AppLaunchEventHandler.java
+++ b/src/main/java/org/cryptomator/ui/fxapp/AppLaunchEventHandler.java
@@ -5,8 +5,8 @@ import org.cryptomator.common.vaults.Vault;
import org.cryptomator.common.vaults.VaultListManager;
import org.cryptomator.launcher.AppLaunchEvent;
import org.cryptomator.launcher.OpenFileEvent;
+import org.cryptomator.launcher.OpenHubVaultEvent;
import org.cryptomator.launcher.RevealRunningEvent;
-import org.cryptomator.launcher.VaultCreationEvent;
import org.cryptomator.ui.common.VaultService;
import org.cryptomator.ui.dialogs.Dialogs;
import org.slf4j.Logger;
@@ -69,7 +69,11 @@ class AppLaunchEventHandler {
switch (event) {
case RevealRunningEvent _ -> appWindows.showMainWindow();
case OpenFileEvent openFileEvent -> openFileEvent.pathsToOpen().forEach(this::openPotentialVault);
- case VaultCreationEvent vaultCreationEvent -> appWindows.showImportTemplateWindow(vaultCreationEvent.name(), vaultCreationEvent.template());
+ // TODO: show the hub vault open flow, see docs/hub-vault-open-deeplink-plan.md
+ case OpenHubVaultEvent openHubVaultEvent -> {
+ LOG.info("Received request to open hub vault {}.", openHubVaultEvent.vaultId());
+ appWindows.showMainWindow();
+ }
}
}
diff --git a/src/test/java/org/cryptomator/launcher/OpenHubVaultEventTest.java b/src/test/java/org/cryptomator/launcher/OpenHubVaultEventTest.java
new file mode 100644
index 000000000..0bdc497c2
--- /dev/null
+++ b/src/test/java/org/cryptomator/launcher/OpenHubVaultEventTest.java
@@ -0,0 +1,208 @@
+package org.cryptomator.launcher;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Optional;
+import java.util.UUID;
+
+public class OpenHubVaultEventTest {
+
+ private static final String VAULT_ID = "d3a1f0b2-7c4e-4a1d-9f3b-2e5c6a7b8c9d";
+ private static final String KEY_ID = "hub+https://hub.example.com/api/vaults/" + VAULT_ID;
+ private static final String TOKEN = hubVaultConfig(KEY_ID);
+
+ @Test
+ @DisplayName("a valid vault/open deeplink is parsed")
+ public void testValid() {
+ var inTest = OpenHubVaultEvent.tryParse(URI.create("org.cryptomator://vault/open#vaultConfig=" + TOKEN)).orElseThrow();
+
+ Assertions.assertEquals(UUID.fromString(VAULT_ID), inTest.vaultId());
+ Assertions.assertEquals(URI.create(KEY_ID), inTest.vaultConfig().getKeyId());
+ }
+
+ @Test
+ @DisplayName("parameters in the query instead of the fragment are not accepted")
+ public void testQueryParamsRejected() {
+ var uri = URI.create("org.cryptomator://vault/open?vaultConfig=" + TOKEN);
+
+ Assertions.assertThrows(IllegalArgumentException.class, () -> OpenHubVaultEvent.tryParse(uri));
+ }
+
+ @Test
+ @DisplayName("an encoded separator inside a value cannot forge another parameter")
+ public void testNoParameterInjectionViaEncodedSeparator() {
+ // '%26' must stay part of the first value; decoding the fragment before splitting would turn it into a real
+ // separator and smuggle in a 'vaultConfig' the link never carried - hence getRawFragment(), decoding per value.
+ var uri = URI.create("org.cryptomator://vault/open#other=a%26vaultConfig=" + TOKEN);
+
+ Assertions.assertThrows(IllegalArgumentException.class, () -> OpenHubVaultEvent.tryParse(uri));
+ }
+
+ @Test
+ @DisplayName("a config exceeding the size limit is rejected")
+ public void testExceedsSizeLimit() {
+ var oversized = "a".repeat(8193);
+
+ Assertions.assertThrows(IllegalArgumentException.class, //
+ () -> OpenHubVaultEvent.tryParse(URI.create("org.cryptomator://vault/open#vaultConfig=" + oversized)));
+ }
+
+ @Test
+ @DisplayName("a config that is not a decodable token is rejected")
+ public void testNotAToken() {
+ Assertions.assertThrows(IllegalArgumentException.class, //
+ () -> OpenHubVaultEvent.tryParse(URI.create("org.cryptomator://vault/open#vaultConfig=not-a-jwt")));
+ }
+
+ @Test
+ @DisplayName("a config without a hub key id is rejected")
+ public void testNotAHubVault() {
+ var token = hubVaultConfig("masterkeyfile:masterkey.cryptomator");
+
+ Assertions.assertThrows(IllegalArgumentException.class, //
+ () -> OpenHubVaultEvent.tryParse(URI.create("org.cryptomator://vault/open#vaultConfig=" + token)));
+ }
+
+ @Test
+ @DisplayName("a config without a hub header is rejected")
+ public void testNoHubHeader() {
+ var token = vaultConfig(KEY_ID, null);
+
+ Assertions.assertThrows(IllegalArgumentException.class, //
+ () -> OpenHubVaultEvent.tryParse(URI.create("org.cryptomator://vault/open#vaultConfig=" + token)));
+ }
+
+ @ParameterizedTest
+ @DisplayName("a key id whose last segment is not a vault id is rejected")
+ @ValueSource(strings = { //
+ "hub+https://hub.example.com/api/vaults/not-a-uuid", //
+ "hub+https://hub.example.com/api/vaults/", // empty segment
+ "hub+https://hub.example.com" // no path at all
+ })
+ public void testInvalidVaultId(String keyId) {
+ var token = hubVaultConfig(keyId);
+
+ Assertions.assertThrows(IllegalArgumentException.class, //
+ () -> OpenHubVaultEvent.tryParse(URI.create("org.cryptomator://vault/open#vaultConfig=" + token)));
+ }
+
+ @ParameterizedTest
+ @DisplayName("an unusable hub endpoint is rejected")
+ @ValueSource(strings = { //
+ "ftp://hub.example.com/api", // neither http nor https
+ "/api", // not absolute
+ "https:///api" // no host
+ })
+ public void testUnusableApiBaseUrl(String apiBaseUrl) {
+ var token = vaultConfig(KEY_ID, hubHeader(apiBaseUrl, "https://login.example.com/auth"));
+
+ Assertions.assertThrows(IllegalArgumentException.class, //
+ () -> OpenHubVaultEvent.tryParse(URI.create("org.cryptomator://vault/open#vaultConfig=" + token)));
+ }
+
+ @Test
+ @DisplayName("an unusable auth endpoint is rejected")
+ public void testUnusableAuthEndpoint() {
+ var token = vaultConfig(KEY_ID, hubHeader("https://hub.example.com/api", "not a url"));
+
+ Assertions.assertThrows(IllegalArgumentException.class, //
+ () -> OpenHubVaultEvent.tryParse(URI.create("org.cryptomator://vault/open#vaultConfig=" + token)));
+ }
+
+ @Test
+ @DisplayName("an http endpoint is accepted here, host trust decides later")
+ public void testHttpEndpointAccepted() {
+ var token = vaultConfig(KEY_ID, hubHeader("http://localhost:8080/api", "http://localhost:8080/auth"));
+
+ Assertions.assertTrue(OpenHubVaultEvent.tryParse(URI.create("org.cryptomator://vault/open#vaultConfig=" + token)).isPresent());
+ }
+
+ @Test
+ @DisplayName("a non-cryptomator scheme yields empty")
+ public void testWrongScheme() {
+ Assertions.assertEquals(Optional.empty(), OpenHubVaultEvent.tryParse(URI.create("foobar://vault/open#vaultConfig=" + TOKEN)));
+ }
+
+ @Test
+ @DisplayName("the bare cryptomator scheme is no longer recognized")
+ public void testLegacyScheme() {
+ Assertions.assertEquals(Optional.empty(), OpenHubVaultEvent.tryParse(URI.create("cryptomator://vault/open#vaultConfig=" + TOKEN)));
+ }
+
+ @Test
+ @DisplayName("an unknown host yields empty")
+ public void testWrongHost() {
+ Assertions.assertEquals(Optional.empty(), OpenHubVaultEvent.tryParse(URI.create("org.cryptomator://foo/open#vaultConfig=" + TOKEN)));
+ }
+
+ @Test
+ @DisplayName("an unknown path yields empty")
+ public void testWrongPath() {
+ Assertions.assertEquals(Optional.empty(), OpenHubVaultEvent.tryParse(URI.create("org.cryptomator://vault/create#vaultConfig=" + TOKEN)));
+ }
+
+ @Test
+ @DisplayName("a matching host is recognized case-insensitively")
+ public void testHostCaseInsensitive() {
+ Assertions.assertTrue(OpenHubVaultEvent.tryParse(URI.create("org.cryptomator://VAULT/open#vaultConfig=" + TOKEN)).isPresent());
+ }
+
+ @Test
+ @DisplayName("a missing config fails")
+ public void testMissingConfig() {
+ Assertions.assertThrows(IllegalArgumentException.class, () -> OpenHubVaultEvent.tryParse(URI.create("org.cryptomator://vault/open")));
+ }
+
+ @Test
+ @DisplayName("a blank config fails")
+ public void testBlankConfig() {
+ Assertions.assertThrows(IllegalArgumentException.class, () -> OpenHubVaultEvent.tryParse(URI.create("org.cryptomator://vault/open#vaultConfig=")));
+ }
+
+ private static String hubHeader(String apiBaseUrl, String authEndpoint) {
+ return """
+ ,
+ "hub": {
+ "clientId":"cryptomator",\
+ "authEndpoint":"%s",\
+ "tokenEndpoint":"https://login.example.com/token",\
+ "authSuccessUrl":"https://hub.example.com/app/unlock-success",\
+ "authErrorUrl":"https://hub.example.com/app/unlock-error",\
+ "apiBaseUrl":"%s"
+ }""".formatted(authEndpoint, apiBaseUrl);
+ }
+
+ private static String hubVaultConfig(String keyId) {
+ return vaultConfig(keyId, hubHeader("https://hub.example.com/api", "https://login.example.com/auth"));
+ }
+
+ /**
+ * Builds a vault config token. Its signature is keyed on the masterkey, which the deeplink never carries, so a dummy
+ * signature is exactly what the parser operates on.
+ */
+ private static String vaultConfig(String keyId, String extraHeaderFields) {
+ var header = """
+ { "kid":"%s",\
+ "typ":"JWT",\
+ "alg":"HS256"\
+ %s
+ }""".formatted(keyId, extraHeaderFields == null ? "" : extraHeaderFields);
+ var payload = """
+ { "format":8,\
+ "cipherCombo":"SIV_GCM",\
+ "shorteningThreshold":220\
+ }""";
+ var encoder = Base64.getUrlEncoder().withoutPadding();
+ return encoder.encodeToString(header.getBytes(StandardCharsets.UTF_8)) //
+ + "." + encoder.encodeToString(payload.getBytes(StandardCharsets.UTF_8)) //
+ + "." + encoder.encodeToString("signature".getBytes(StandardCharsets.UTF_8));
+ }
+
+}
diff --git a/src/test/java/org/cryptomator/launcher/VaultCreationEventTest.java b/src/test/java/org/cryptomator/launcher/VaultCreationEventTest.java
deleted file mode 100644
index 056c4bfb7..000000000
--- a/src/test/java/org/cryptomator/launcher/VaultCreationEventTest.java
+++ /dev/null
@@ -1,159 +0,0 @@
-package org.cryptomator.launcher;
-
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.ValueSource;
-
-import java.net.URI;
-import java.nio.charset.StandardCharsets;
-import java.util.Base64;
-import java.util.Optional;
-
-public class VaultCreationEventTest {
-
- private static final byte[] TEMPLATE_BYTES = "a-ready-made-vault-zip".getBytes(StandardCharsets.UTF_8);
- private static final String TEMPLATE_B64 = Base64.getUrlEncoder().withoutPadding().encodeToString(TEMPLATE_BYTES);
-
- @Test
- @DisplayName("a valid vault/create deeplink is parsed")
- public void testValid() {
- var inTest = VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create#name=MyVault&template=" + TEMPLATE_B64)).orElseThrow();
-
- Assertions.assertEquals("MyVault", inTest.name());
- Assertions.assertArrayEquals(TEMPLATE_BYTES, inTest.template());
- }
-
- @Test
- @DisplayName("parameters in the query instead of the fragment are not accepted")
- public void testQueryParamsRejected() {
- var uri = URI.create("cryptomator://vault/create?name=MyVault&template=" + TEMPLATE_B64);
-
- Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(uri));
- }
-
- @Test
- @DisplayName("an encoded separator inside a value cannot forge another parameter")
- public void testNoParameterInjectionViaEncodedSeparator() {
- // '%26' must stay part of the name's value; decoding the fragment before splitting would turn it into a real
- // separator and smuggle in a 'template' the link never carried - hence getRawFragment(), decoding per value.
- var uri = URI.create("cryptomator://vault/create#name=a%26template=" + TEMPLATE_B64);
-
- Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(uri));
- }
-
- @ParameterizedTest
- @DisplayName("a name with a disallowed character is rejected")
- @ValueSource(strings = { //
- "foo%2Fbar", // path separator
- "foo%5Cbar", // backslash
- "..", // parent directory
- ".", // this directory
- "foo%20", // trailing whitespace
- "foo%09", // trailing tab
- "Rechnung%E2%80%AEgnp.exe", // U+202E right-to-left override
- "foo%E2%80%8Bbar", // U+200B zero-width space
- "foo%00bar" // NUL
- })
- public void testDisallowedName(String encodedName) {
- var uri = URI.create("cryptomator://vault/create#name=" + encodedName + "&template=" + TEMPLATE_B64);
-
- Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(uri));
- }
-
- @Test
- @DisplayName("a name of exactly the maximum length is accepted")
- public void testNameAtLengthLimit() {
- var name = "a".repeat(256);
-
- var inTest = VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create#name=" + name + "&template=" + TEMPLATE_B64)).orElseThrow();
-
- Assertions.assertEquals(name, inTest.name());
- }
-
- @Test
- @DisplayName("a name exceeding the maximum length is rejected")
- public void testNameExceedsLengthLimit() {
- var uri = URI.create("cryptomator://vault/create#name=" + "a".repeat(257) + "&template=" + TEMPLATE_B64);
-
- Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(uri));
- }
-
- @Test
- @DisplayName("a name with non-ASCII letters is accepted")
- public void testNameWithUmlauts() {
- var inTest = VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create#name=Gesch%C3%A4ftsberichte&template=" + TEMPLATE_B64)).orElseThrow();
-
- Assertions.assertEquals("Geschäftsberichte", inTest.name());
- }
-
- @Test
- @DisplayName("a URL-encoded name is decoded")
- public void testUrlEncodedName() {
- var inTest = VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create#name=My%20Vault&template=" + TEMPLATE_B64)).orElseThrow();
-
- Assertions.assertEquals("My Vault", inTest.name());
- }
-
- @Test
- @DisplayName("a non-cryptomator scheme yields empty")
- public void testWrongScheme() {
- Assertions.assertEquals(Optional.empty(), VaultCreationEvent.tryParse(URI.create("foobar://vault/create#name=MyVault&template=" + TEMPLATE_B64)));
- }
-
- @Test
- @DisplayName("an unknown host yields empty")
- public void testWrongHost() {
- Assertions.assertEquals(Optional.empty(), VaultCreationEvent.tryParse(URI.create("cryptomator://foo/create#name=MyVault&template=" + TEMPLATE_B64)));
- }
-
- @Test
- @DisplayName("an unknown path yields empty")
- public void testWrongPath() {
- Assertions.assertEquals(Optional.empty(), VaultCreationEvent.tryParse(URI.create("cryptomator://vault/bar#name=MyVault&template=" + TEMPLATE_B64)));
- }
-
- @Test
- @DisplayName("a matching host is recognized case-insensitively")
- public void testHostCaseInsensitive() {
- Assertions.assertTrue(VaultCreationEvent.tryParse(URI.create("cryptomator://VAULT/create#name=MyVault&template=" + TEMPLATE_B64)).isPresent());
- }
-
- @Test
- @DisplayName("a missing name fails")
- public void testMissingName() {
- Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create#template=" + TEMPLATE_B64)));
- }
-
- @Test
- @DisplayName("a blank name fails")
- public void testBlankName() {
- Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create#name=&template=" + TEMPLATE_B64)));
- }
-
- @Test
- @DisplayName("a missing template fails")
- public void testMissingTemplate() {
- Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create#name=MyVault")));
- }
-
- @Test
- @DisplayName("an invalid Base64URL template fails")
- public void testInvalidTemplate() {
- Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create#name=MyVault&template=@@@")));
- }
-
- @Test
- @DisplayName("a name containing a path separator fails")
- public void testNameWithSlash() {
- Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create#name=foo%2Fbar&template=" + TEMPLATE_B64)));
- }
-
- @Test
- @DisplayName("a name with parent-dir traversal fails")
- public void testNameWithTraversal() {
- Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create#name=..&template=" + TEMPLATE_B64)));
- }
-
-}
diff --git a/src/test/java/org/cryptomator/ui/fxapp/AppLaunchEventHandlerTest.java b/src/test/java/org/cryptomator/ui/fxapp/AppLaunchEventHandlerTest.java
index 32a58107d..f5994c105 100644
--- a/src/test/java/org/cryptomator/ui/fxapp/AppLaunchEventHandlerTest.java
+++ b/src/test/java/org/cryptomator/ui/fxapp/AppLaunchEventHandlerTest.java
@@ -3,7 +3,6 @@ package org.cryptomator.ui.fxapp;
import org.cryptomator.common.vaults.VaultListManager;
import org.cryptomator.launcher.AppLaunchEvent;
import org.cryptomator.launcher.RevealRunningEvent;
-import org.cryptomator.launcher.VaultCreationEvent;
import org.cryptomator.ui.common.VaultService;
import org.cryptomator.ui.dialogs.Dialogs;
import org.junit.jupiter.api.AfterEach;
@@ -41,16 +40,7 @@ public class AppLaunchEventHandlerTest {
executor.shutdownNow();
}
- @Test
- @DisplayName("a VaultCreationEvent opens the import-template window with name and template")
- public void testVaultCreationEventOpensImportTemplateWindow() {
- var template = new byte[]{1, 2, 3};
- queue.add(new VaultCreationEvent("MyVault", template));
-
- handler.startHandlingLaunchEvents();
-
- verify(appWindows, timeout(2000)).showImportTemplateWindow("MyVault", template);
- }
+ // TODO: Add test for OpenHubVaultEvent once the event opens the hub vault flow for real.
@Test
@DisplayName("a RevealRunningEvent reveals the main window")