Refactor VaultCreationEvent to OpenHubVaultEvent

and change deeplink URL scheme to org.cryptomator

Signed-off-by: Armin Schrenk <armin.schrenk@skymatic.de>
This commit is contained in:
Armin Schrenk
2026-08-26 16:50:26 +02:00
parent 2c53afd70f
commit 27eb6298ae
11 changed files with 395 additions and 298 deletions
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -46,7 +46,7 @@
<string>Any</string>
</dict>
</dict>
<!-- register cryptomator:// URL scheme -->
<!-- register org.cryptomator:// URL scheme -->
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -56,7 +56,7 @@
<string>Viewer</string>
<key>CFBundleURLSchemes</key>
<array>
<string>cryptomator</string>
<string>org.cryptomator</string>
</array>
</dict>
</array>
+2 -2
View File
@@ -27,7 +27,7 @@
<?define ProgIdContentType= "application/vnd.cryptomator.encrypted" ?>
<?define CloseApplicationTarget= "cryptomator.exe" ?>
<?define LoopbackAlias= "cryptomator-vault" ?>
<?define UrlProtocolScheme= "cryptomator" ?>
<?define UrlProtocolScheme= "org.cryptomator" ?>
<?include $(var.JpConfigDir)/overrides.wxi ?>
@@ -98,7 +98,7 @@
<ns0:Extension Id="c9u" Advertise="no" ContentType="$(var.ProgIdContentType)"/>
</ns0:ProgId>
</ns0:Component>
<!-- Register "cryptomator://" URL protocol handler -->
<!-- Register "org.cryptomator://" URL protocol handler -->
<ns0:Component Bitness="always64" Id="UrlProtocolHandler" Guid="*">
<ns0:RegistryKey Root="HKMU" Key="Software\Classes\$(var.UrlProtocolScheme)">
<ns0:RegistryValue Type="string" Value="URL:$(var.JpAppName) Protocol" KeyPath="yes"/>
@@ -8,9 +8,9 @@ package org.cryptomator.launcher;
* <ul>
* <li>{@link RevealRunningEvent} - reveal the already-running app,</li>
* <li>{@link OpenFileEvent} - open one or more paths,</li>
* <li>{@link VaultCreationEvent} - create a vault from a deeplink.</li>
* <li>{@link OpenHubVaultEvent} - open a Hub vault from a deeplink.</li>
* </ul>
*/
public sealed interface AppLaunchEvent permits RevealRunningEvent, OpenFileEvent, VaultCreationEvent {
public sealed interface AppLaunchEvent permits RevealRunningEvent, OpenFileEvent, OpenHubVaultEvent {
}
@@ -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.
* <p>
* 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.
* <p>
* The config is read <em>unverified</em>: 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<OpenHubVaultEvent> 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/<vaultId>}.
* <p>
* 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<String, String> parseParams(String rawParams) {
var params = new HashMap<String, String>();
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;
}
}
@@ -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<Function<URI, Optional<? extends AppLaunchEvent>>> DEEPLINK_PARSERS = List.of( //
VaultCreationEvent::tryParse //
OpenHubVaultEvent::tryParse //
);
private final BlockingQueue<AppLaunchEvent> launchEventQueue;
@@ -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.
* <p>
* 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
* <em>fragment</em> 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<VaultCreationEvent> 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. "Rechnung<U+202E>gnp.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<String, String> parseParams(String rawParams) {
var params = new HashMap<String, String>();
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;
}
}
@@ -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();
}
}
}
@@ -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));
}
}
@@ -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)));
}
}
@@ -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")