diff --git a/src/main/java/org/cryptomator/launcher/DeeplinkAction.java b/src/main/java/org/cryptomator/launcher/DeeplinkAction.java
new file mode 100644
index 000000000..c110ab393
--- /dev/null
+++ b/src/main/java/org/cryptomator/launcher/DeeplinkAction.java
@@ -0,0 +1,38 @@
+package org.cryptomator.launcher;
+
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Optional;
+
+/**
+ * Enumerates the supported deeplink actions and maps an incoming {@link URI} to one of them.
+ *
+ * A deeplink action is identified by the URI's host and path, e.g. {@code cryptomator://vault/create} maps to
+ * {@link #VAULT_CREATE}. Unknown host/path combinations yield an empty {@link Optional} and should be treated as
+ * unsupported. New actions are added by extending this enum.
+ */
+public enum DeeplinkAction {
+
+ VAULT_CREATE("vault", "/create");
+
+ private final String host;
+ private final String path;
+
+ DeeplinkAction(String host, String path) {
+ this.host = host;
+ this.path = path;
+ }
+
+ /**
+ * Matches the given URI against the known deeplink actions.
+ *
+ * @param uri the deeplink URI
+ * @return the matching action, or an empty optional if the host/path is not recognized
+ */
+ public static Optional match(URI uri) {
+ return Arrays.stream(values()) //
+ .filter(action -> action.host.equalsIgnoreCase(uri.getHost()) && action.path.equals(uri.getPath())) //
+ .findFirst();
+ }
+
+}
diff --git a/src/main/java/org/cryptomator/launcher/VaultCreateDeeplink.java b/src/main/java/org/cryptomator/launcher/VaultCreateDeeplink.java
new file mode 100644
index 000000000..c82987f82
--- /dev/null
+++ b/src/main/java/org/cryptomator/launcher/VaultCreateDeeplink.java
@@ -0,0 +1,82 @@
+package org.cryptomator.launcher;
+
+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 org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Parsed representation of 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 so that resolving it against a
+ * user-chosen location cannot escape that location.
+ *
+ * @param name the fixed vault name (a single, safe path segment)
+ * @param template the Base64URL-decoded vault template
+ */
+public record VaultCreateDeeplink(String name, byte[] template) {
+
+ private static final Logger LOG = LoggerFactory.getLogger(VaultCreateDeeplink.class);
+
+ /**
+ * Parses the query parameters of a {@code vault/create} deeplink.
+ *
+ * @param uri the deeplink URI
+ * @return the parsed deeplink
+ * @throws IllegalArgumentException if a required parameter is missing, the template is not valid Base64URL, or the
+ * name is not a single safe path segment
+ */
+ public static VaultCreateDeeplink parse(URI uri) {
+ var params = parseQuery(uri.getRawQuery());
+ var name = params.get("name");
+ var templateParam = params.get("template");
+ if (name == null || name.isBlank()) {
+ throw new IllegalArgumentException("Missing required query parameter 'name'.");
+ }
+ if (templateParam == null || templateParam.isEmpty()) {
+ throw new IllegalArgumentException("Missing required query parameter 'template'.");
+ }
+ validateName(name);
+ byte[] template;
+ try {
+ template = Base64.getUrlDecoder().decode(templateParam);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Query 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 unknow parameters {}", leftoverParams);
+ }
+ return new VaultCreateDeeplink(name, template);
+ }
+
+ private static void validateName(String name) {
+ if (name.contains("/") || name.contains("\\") || name.contains("..") || name.equals(".")) {
+ throw new IllegalArgumentException("Query parameter 'name' must be a single path segment, but was '" + name + "'.");
+ }
+ }
+
+ private static Map parseQuery(String rawQuery) {
+ var params = new HashMap();
+ if (rawQuery == null || rawQuery.isEmpty()) {
+ return params;
+ }
+ for (var pair : rawQuery.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/test/java/org/cryptomator/launcher/DeeplinkActionTest.java b/src/test/java/org/cryptomator/launcher/DeeplinkActionTest.java
new file mode 100644
index 000000000..9968a5566
--- /dev/null
+++ b/src/test/java/org/cryptomator/launcher/DeeplinkActionTest.java
@@ -0,0 +1,40 @@
+package org.cryptomator.launcher;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.util.Optional;
+
+public class DeeplinkActionTest {
+
+ @Test
+ @DisplayName("cryptomator://vault/create maps to VAULT_CREATE")
+ public void testVaultCreateMatches() {
+ var result = DeeplinkAction.match(URI.create("cryptomator://vault/create?name=foo&template=bar"));
+
+ Assertions.assertEquals(Optional.of(DeeplinkAction.VAULT_CREATE), result);
+ }
+
+ @Test
+ @DisplayName("host matching is case-insensitive")
+ public void testHostCaseInsensitive() {
+ var result = DeeplinkAction.match(URI.create("cryptomator://VAULT/create"));
+
+ Assertions.assertEquals(Optional.of(DeeplinkAction.VAULT_CREATE), result);
+ }
+
+ @Test
+ @DisplayName("an unknown host yields empty")
+ public void testUnknownHost() {
+ Assertions.assertEquals(Optional.empty(), DeeplinkAction.match(URI.create("cryptomator://foo/create")));
+ }
+
+ @Test
+ @DisplayName("an unknown path yields empty")
+ public void testUnknownPath() {
+ Assertions.assertEquals(Optional.empty(), DeeplinkAction.match(URI.create("cryptomator://vault/bar")));
+ }
+
+}
diff --git a/src/test/java/org/cryptomator/launcher/VaultCreateDeeplinkTest.java b/src/test/java/org/cryptomator/launcher/VaultCreateDeeplinkTest.java
new file mode 100644
index 000000000..723f528ad
--- /dev/null
+++ b/src/test/java/org/cryptomator/launcher/VaultCreateDeeplinkTest.java
@@ -0,0 +1,69 @@
+package org.cryptomator.launcher;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+public class VaultCreateDeeplinkTest {
+
+ 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 = VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=MyVault&template=" + TEMPLATE_B64));
+
+ Assertions.assertEquals("MyVault", inTest.name());
+ Assertions.assertArrayEquals(TEMPLATE_BYTES, inTest.template());
+ }
+
+ @Test
+ @DisplayName("a URL-encoded name is decoded")
+ public void testUrlEncodedName() {
+ var inTest = VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=My%20Vault&template=" + TEMPLATE_B64));
+
+ Assertions.assertEquals("My Vault", inTest.name());
+ }
+
+ @Test
+ @DisplayName("a missing name fails")
+ public void testMissingName() {
+ Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?template=" + TEMPLATE_B64)));
+ }
+
+ @Test
+ @DisplayName("a blank name fails")
+ public void testBlankName() {
+ Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=&template=" + TEMPLATE_B64)));
+ }
+
+ @Test
+ @DisplayName("a missing template fails")
+ public void testMissingTemplate() {
+ Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=MyVault")));
+ }
+
+ @Test
+ @DisplayName("an invalid Base64URL template fails")
+ public void testInvalidTemplate() {
+ Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=MyVault&template=@@@")));
+ }
+
+ @Test
+ @DisplayName("a name containing a path separator fails")
+ public void testNameWithSlash() {
+ Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(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, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=..&template=" + TEMPLATE_B64)));
+ }
+
+}