mirror of
https://github.com/cryptomator/cryptomator.git
synced 2026-09-21 23:44:42 +00:00
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:
+1
-1
@@ -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;
|
||||
|
||||
Vendored
+2
-2
@@ -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>
|
||||
|
||||
Vendored
+2
-2
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user