From 27eb6298ae020a48b1fb55288c214cbe8a37ed69 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 26 Aug 2026 16:50:26 +0200 Subject: [PATCH] Refactor VaultCreationEvent to OpenHubVaultEvent and change deeplink URL scheme to org.cryptomator Signed-off-by: Armin Schrenk --- .../org.cryptomator.Cryptomator.desktop | 2 +- dist/mac/resources/Info.plist | 4 +- dist/win/resources/main.wxs | 4 +- .../cryptomator/launcher/AppLaunchEvent.java | 4 +- .../launcher/OpenHubVaultEvent.java | 172 +++++++++++++++ .../launcher/URIOpenRequestHandler.java | 2 +- .../launcher/VaultCreationEvent.java | 118 ---------- .../ui/fxapp/AppLaunchEventHandler.java | 8 +- .../launcher/OpenHubVaultEventTest.java | 208 ++++++++++++++++++ .../launcher/VaultCreationEventTest.java | 159 ------------- .../ui/fxapp/AppLaunchEventHandlerTest.java | 12 +- 11 files changed, 395 insertions(+), 298 deletions(-) create mode 100644 src/main/java/org/cryptomator/launcher/OpenHubVaultEvent.java delete mode 100644 src/main/java/org/cryptomator/launcher/VaultCreationEvent.java create mode 100644 src/test/java/org/cryptomator/launcher/OpenHubVaultEventTest.java delete mode 100644 src/test/java/org/cryptomator/launcher/VaultCreationEventTest.java diff --git a/dist/linux/common/org.cryptomator.Cryptomator.desktop b/dist/linux/common/org.cryptomator.Cryptomator.desktop index 456eb62e0..15dbedbb4 100644 --- a/dist/linux/common/org.cryptomator.Cryptomator.desktop +++ b/dist/linux/common/org.cryptomator.Cryptomator.desktop @@ -8,4 +8,4 @@ Type=Application Categories=Utility;Security;FileTools; StartupNotify=true StartupWMClass=org.cryptomator.launcher.Cryptomator$MainApp -MimeType=application/vnd.cryptomator.encrypted;application/vnd.cryptomator.vault;x-scheme-handler/cryptomator; +MimeType=application/vnd.cryptomator.encrypted;application/vnd.cryptomator.vault;x-scheme-handler/org.cryptomator; diff --git a/dist/mac/resources/Info.plist b/dist/mac/resources/Info.plist index 261bb0851..b05ebea6f 100644 --- a/dist/mac/resources/Info.plist +++ b/dist/mac/resources/Info.plist @@ -46,7 +46,7 @@ Any - + CFBundleURLTypes @@ -56,7 +56,7 @@ Viewer CFBundleURLSchemes - cryptomator + org.cryptomator diff --git a/dist/win/resources/main.wxs b/dist/win/resources/main.wxs index 6db452590..7da964943 100644 --- a/dist/win/resources/main.wxs +++ b/dist/win/resources/main.wxs @@ -27,7 +27,7 @@ - + @@ -98,7 +98,7 @@ - + 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")