From 729f38866fb3e69506e0ef0c66c04ed34fdd2cfb Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Fri, 19 Jan 2024 17:40:39 +0100 Subject: [PATCH 1/9] use string templates for URI construction --- .../ui/keyloading/hub/RegisterDeviceController.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java b/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java index 08af2492a..0db265b3b 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java @@ -32,6 +32,7 @@ import javafx.stage.Stage; import javafx.stage.WindowEvent; import java.io.IOException; import java.net.InetAddress; +import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -62,6 +63,7 @@ public class RegisterDeviceController implements FxController { private final P384KeyPair deviceKeyPair; private final CompletableFuture result; private final HttpClient httpClient; + private final StringTemplate.Processor API_BASE = this::resolveRelativeToApiBase; private final BooleanProperty invalidSetupCode = new SimpleBooleanProperty(false); private final BooleanProperty workInProgress = new SimpleBooleanProperty(false); @@ -108,9 +110,8 @@ public class RegisterDeviceController implements FxController { public void register() { workInProgress.set(true); - var apiRootUrl = hubConfig.getApiBaseUrl(); - var userReq = HttpRequest.newBuilder(apiRootUrl.resolve("users/me")) // + var userReq = HttpRequest.newBuilder(API_BASE."users/me") // .GET() // .timeout(REQ_TIMEOUT) // .header("Authorization", "Bearer " + bearerToken) // @@ -136,7 +137,7 @@ public class RegisterDeviceController implements FxController { var now = Instant.now().toString(); var dto = new CreateDeviceDto(deviceId, deviceNameField.getText(), BaseEncoding.base64().encode(deviceKeyPair.getPublic().getEncoded()), "DESKTOP", jwe.serialize(), now); var json = toJson(dto); - var deviceUri = apiRootUrl.resolve("devices/" + deviceId); + var deviceUri = API_BASE."devices/\{deviceId}"; var putDeviceReq = HttpRequest.newBuilder(deviceUri) // .PUT(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) // .timeout(REQ_TIMEOUT) // @@ -204,6 +205,12 @@ public class RegisterDeviceController implements FxController { result.cancel(true); } + private URI resolveRelativeToApiBase(StringTemplate template) { + var path = template.interpolate(); + var relPath = path.startsWith("/") ? path.substring(1) : path; + return hubConfig.getApiBaseUrl().resolve(relPath); + } + //--- Getters & Setters public BooleanProperty invalidSetupCodeProperty() { return invalidSetupCode; From dc5d6e734eae58e6424ec575f2ead001cf57690c Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Fri, 19 Jan 2024 17:55:36 +0100 Subject: [PATCH 2/9] deduplicate code --- .../ui/keyloading/hub/HubConfig.java | 21 +++++++++++++++++++ .../keyloading/hub/ReceiveKeyController.java | 15 ++++--------- .../hub/RegisterDeviceController.java | 11 ++-------- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/HubConfig.java b/src/main/java/org/cryptomator/ui/keyloading/hub/HubConfig.java index 84cac8ed2..eefad55a2 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/HubConfig.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/HubConfig.java @@ -1,5 +1,6 @@ package org.cryptomator.ui.keyloading.hub; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import org.jetbrains.annotations.Nullable; @@ -18,11 +19,18 @@ public class HubConfig { @Deprecated // use apiBaseUrl + "/devices/" public String devicesResourceUrl; + /** + * A collection of String template processors to construct URIs related to this Hub instance. + */ + @JsonIgnore + public final URIProcessors URIs = new URIProcessors(); + /** * Get the URI pointing to the /api/ base resource. * * @return /api/ URI * @apiNote URI is guaranteed to end on / + * @see #URIs */ public URI getApiBaseUrl() { if (apiBaseUrl != null) { @@ -38,4 +46,17 @@ public class HubConfig { public URI getWebappBaseUrl() { return getApiBaseUrl().resolve("../app/"); } + + public class URIProcessors { + + /** + * Resolves paths relative to the /api/ endpoint of this Hub instance. + */ + public final StringTemplate.Processor API = template -> { + var path = template.interpolate(); + var relPath = path.startsWith("/") ? path.substring(1) : path; + return getApiBaseUrl().resolve(relPath); + }; + + } } diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java b/src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java index 11840f989..3bfb4ec8e 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java @@ -57,7 +57,6 @@ public class ReceiveKeyController implements FxController { private final Lazy accountInitializationScene; private final Lazy invalidLicenseScene; private final HttpClient httpClient; - private final StringTemplate.Processor API_BASE = this::resolveRelativeToApiBase; @Inject public ReceiveKeyController(@KeyLoading Vault vault, ExecutorService executor, @KeyLoading Stage window, HubConfig hubConfig, @Named("deviceId") String deviceId, @Named("bearerToken") AtomicReference tokenRef, CompletableFuture result, @FxmlScene(FxmlFile.HUB_REGISTER_DEVICE) Lazy registerDeviceScene, @FxmlScene(FxmlFile.HUB_LEGACY_REGISTER_DEVICE) Lazy legacyRegisterDeviceScene, @FxmlScene(FxmlFile.HUB_UNAUTHORIZED_DEVICE) Lazy unauthorizedScene, @FxmlScene(FxmlFile.HUB_REQUIRE_ACCOUNT_INIT) Lazy accountInitializationScene, @FxmlScene(FxmlFile.HUB_INVALID_LICENSE) Lazy invalidLicenseScene) { @@ -89,7 +88,7 @@ public class ReceiveKeyController implements FxController { * STEP 0 (Request): GET /api/config */ private void requestApiConfig() { - var configUri = API_BASE."config"; + var configUri = hubConfig.URIs.API."config"; var request = HttpRequest.newBuilder(configUri) // .GET() // .timeout(REQ_TIMEOUT) // @@ -123,7 +122,7 @@ public class ReceiveKeyController implements FxController { * STEP 1 (Request): GET user key for this device */ private void requestDeviceData() { - var deviceUri = API_BASE."devices/\{deviceId}"; + var deviceUri = hubConfig.URIs.API."devices/\{deviceId}"; var request = HttpRequest.newBuilder(deviceUri) // .header("Authorization", "Bearer " + bearerToken) // .GET() // @@ -163,7 +162,7 @@ public class ReceiveKeyController implements FxController { * STEP 2 (Request): GET vault key for this user */ private void requestVaultMasterkey(String encryptedUserKey) { - var vaultKeyUri = API_BASE."vaults/\{vaultId}/access-token"; + var vaultKeyUri = hubConfig.URIs.API."vaults/\{vaultId}/access-token"; var request = HttpRequest.newBuilder(vaultKeyUri) // .header("Authorization", "Bearer " + bearerToken) // .GET() // @@ -206,7 +205,7 @@ public class ReceiveKeyController implements FxController { */ @Deprecated private void requestLegacyAccessToken() { - var legacyAccessTokenUri = API_BASE."vaults/\{vaultId}/keys/\{deviceId}"; + var legacyAccessTokenUri = hubConfig.URIs.API."vaults/\{vaultId}/keys/\{deviceId}"; var request = HttpRequest.newBuilder(legacyAccessTokenUri) // .header("Authorization", "Bearer " + bearerToken) // .GET() // @@ -288,12 +287,6 @@ public class ReceiveKeyController implements FxController { } } - private URI resolveRelativeToApiBase(StringTemplate template) { - var path = template.interpolate(); - var relPath = path.startsWith("/") ? path.substring(1) : path; - return hubConfig.getApiBaseUrl().resolve(relPath); - } - private static String extractVaultId(URI vaultKeyUri) { assert vaultKeyUri.getScheme().startsWith(SCHEME_PREFIX); var path = vaultKeyUri.getPath(); diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java b/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java index 0db265b3b..f604cd489 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java @@ -63,7 +63,6 @@ public class RegisterDeviceController implements FxController { private final P384KeyPair deviceKeyPair; private final CompletableFuture result; private final HttpClient httpClient; - private final StringTemplate.Processor API_BASE = this::resolveRelativeToApiBase; private final BooleanProperty invalidSetupCode = new SimpleBooleanProperty(false); private final BooleanProperty workInProgress = new SimpleBooleanProperty(false); @@ -111,7 +110,7 @@ public class RegisterDeviceController implements FxController { workInProgress.set(true); - var userReq = HttpRequest.newBuilder(API_BASE."users/me") // + var userReq = HttpRequest.newBuilder(hubConfig.URIs.API."users/me") // .GET() // .timeout(REQ_TIMEOUT) // .header("Authorization", "Bearer " + bearerToken) // @@ -137,7 +136,7 @@ public class RegisterDeviceController implements FxController { var now = Instant.now().toString(); var dto = new CreateDeviceDto(deviceId, deviceNameField.getText(), BaseEncoding.base64().encode(deviceKeyPair.getPublic().getEncoded()), "DESKTOP", jwe.serialize(), now); var json = toJson(dto); - var deviceUri = API_BASE."devices/\{deviceId}"; + var deviceUri = hubConfig.URIs.API."devices/\{deviceId}"; var putDeviceReq = HttpRequest.newBuilder(deviceUri) // .PUT(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) // .timeout(REQ_TIMEOUT) // @@ -205,12 +204,6 @@ public class RegisterDeviceController implements FxController { result.cancel(true); } - private URI resolveRelativeToApiBase(StringTemplate template) { - var path = template.interpolate(); - var relPath = path.startsWith("/") ? path.substring(1) : path; - return hubConfig.getApiBaseUrl().resolve(relPath); - } - //--- Getters & Setters public BooleanProperty invalidSetupCodeProperty() { return invalidSetupCode; From 35eb548d8e751ddb305e755734b6ec77221bca01 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 20 Jan 2024 12:58:44 +0100 Subject: [PATCH 3/9] replaced Guava's `BaseEncoding` with `Base64` --- src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java b/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java index 2333051be..a2e45cd71 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java @@ -1,7 +1,6 @@ package org.cryptomator.ui.keyloading.hub; import com.google.common.base.Preconditions; -import com.google.common.io.BaseEncoding; import com.nimbusds.jose.EncryptionMethod; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWEAlgorithm; @@ -108,7 +107,7 @@ class JWEHelper { var keyBytes = new byte[0]; try { if (fields.get(keyField) instanceof String key) { - keyBytes = BaseEncoding.base64().decode(key); + keyBytes = Base64.getDecoder().decode(key); return rawKeyFactory.apply(keyBytes); } else { throw new IllegalArgumentException("JWE payload doesn't contain field " + keyField); From e9ee17493b572e6ed17bf4ec02746edec883e0d0 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 20 Jan 2024 13:01:48 +0100 Subject: [PATCH 4/9] refactor API, expose `decodeECPrivateKey(byte[])` --- .../ui/keyloading/hub/JWEHelper.java | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java b/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java index a2e45cd71..6ef60610a 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java @@ -12,14 +12,13 @@ import com.nimbusds.jose.crypto.ECDHEncrypter; import com.nimbusds.jose.crypto.PasswordBasedDecrypter; import com.nimbusds.jose.jwk.Curve; import com.nimbusds.jose.jwk.gen.ECKeyGenerator; -import com.nimbusds.jose.jwk.gen.JWKGenerator; +import org.cryptomator.cryptolib.api.CryptoException; import org.cryptomator.cryptolib.api.Masterkey; import org.cryptomator.cryptolib.api.MasterkeyLoadingFailedException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.security.KeyFactory; -import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.security.interfaces.ECPrivateKey; import java.security.interfaces.ECPublicKey; @@ -36,7 +35,8 @@ class JWEHelper { private static final String JWE_PAYLOAD_KEY_FIELD = "key"; private static final String EC_ALG = "EC"; - private JWEHelper(){} + private JWEHelper() {} + public static JWEObject encryptUserKey(ECPrivateKey userKey, ECPublicKey deviceKey) { try { var encodedUserKey = Base64.getEncoder().encodeToString(userKey.getEncoded()); @@ -55,7 +55,7 @@ class JWEHelper { public static ECPrivateKey decryptUserKey(JWEObject jwe, String setupCode) throws InvalidJweKeyException { try { jwe.decrypt(new PasswordBasedDecrypter(setupCode)); - return decodeUserKey(jwe); + return readKey(jwe, JWE_PAYLOAD_KEY_FIELD, JWEHelper::decodeECPrivateKey); } catch (JOSEException e) { throw new InvalidJweKeyException(e); } @@ -64,17 +64,23 @@ class JWEHelper { public static ECPrivateKey decryptUserKey(JWEObject jwe, ECPrivateKey deviceKey) throws InvalidJweKeyException { try { jwe.decrypt(new ECDHDecrypter(deviceKey)); - return decodeUserKey(jwe); + return readKey(jwe, JWE_PAYLOAD_KEY_FIELD, JWEHelper::decodeECPrivateKey); } catch (JOSEException e) { throw new InvalidJweKeyException(e); } } - private static ECPrivateKey decodeUserKey(JWEObject decryptedJwe) { + /** + * Attempts to decode a DER-encoded EC private key. + * + * @param encoded DER-encoded EC private key + * @return the decoded key + * @throws KeyDecodeFailedException On malformed input + */ + public static ECPrivateKey decodeECPrivateKey(byte[] encoded) throws KeyDecodeFailedException { try { - var keySpec = readKey(decryptedJwe, JWE_PAYLOAD_KEY_FIELD, PKCS8EncodedKeySpec::new); - var factory = KeyFactory.getInstance(EC_ALG); - var privateKey = factory.generatePrivate(keySpec); + KeyFactory factory = KeyFactory.getInstance(EC_ALG); + var privateKey = factory.generatePrivate(new PKCS8EncodedKeySpec(encoded)); if (privateKey instanceof ECPrivateKey ecPrivateKey) { return ecPrivateKey; } else { @@ -83,8 +89,9 @@ class JWEHelper { } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(EC_ALG + " not supported"); } catch (InvalidKeySpecException e) { - LOG.warn("Unexpected JWE payload: {}", decryptedJwe.getPayload()); - throw new MasterkeyLoadingFailedException("Unexpected JWE payload", e); + throw new KeyDecodeFailedException(e); + } + } } } @@ -112,7 +119,7 @@ class JWEHelper { } else { throw new IllegalArgumentException("JWE payload doesn't contain field " + keyField); } - } catch (IllegalArgumentException e) { + } catch (IllegalArgumentException | KeyDecodeFailedException e) { LOG.error("Unexpected JWE payload: {}", jwe.getPayload()); throw new MasterkeyLoadingFailedException("Unexpected JWE payload", e); } finally { @@ -126,4 +133,11 @@ class JWEHelper { super("Invalid key", cause); } } + + public static class KeyDecodeFailedException extends CryptoException { + + public KeyDecodeFailedException(Throwable cause) { + super("Malformed key", cause); + } + } } From 2e443c72a9aaf1593ed96f2889716f8fc1fab5e3 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 20 Jan 2024 13:12:59 +0100 Subject: [PATCH 5/9] add new API `encryptVaultKey(vaultKey, userKey)` and `decodeECPublicKey(byte[])` --- .../ui/keyloading/hub/JWEHelper.java | 37 +++++++++++++++++++ .../ui/keyloading/hub/JWEHelperTest.java | 28 ++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java b/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java index 6ef60610a..313ec48c9 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java @@ -24,6 +24,7 @@ import java.security.interfaces.ECPrivateKey; import java.security.interfaces.ECPublicKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; import java.util.Arrays; import java.util.Base64; import java.util.Map; @@ -92,6 +93,42 @@ class JWEHelper { throw new KeyDecodeFailedException(e); } } + + /** + * Attempts to decode a DER-encoded EC public key. + * + * @param encoded DER-encoded EC public key + * @return the decoded key + * @throws KeyDecodeFailedException On malformed input + */ + public static ECPublicKey decodeECPublicKey(byte[] encoded) throws KeyDecodeFailedException { + try { + KeyFactory factory = KeyFactory.getInstance(EC_ALG); + var publicKey = factory.generatePublic(new X509EncodedKeySpec(encoded)); + if (publicKey instanceof ECPublicKey ecPublicKey) { + return ecPublicKey; + } else { + throw new IllegalStateException(EC_ALG + " key factory not generating ECPublicKeys"); + } + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(EC_ALG + " not supported"); + } catch (InvalidKeySpecException e) { + throw new KeyDecodeFailedException(e); + } + } + + public static JWEObject encryptVaultKey(Masterkey vaultKey, ECPublicKey userKey) { + try { + var encodedVaultKey = Base64.getEncoder().encodeToString(vaultKey.getEncoded()); + var keyGen = new ECKeyGenerator(Curve.P_384); + var ephemeralKeyPair = keyGen.generate(); + var header = new JWEHeader.Builder(JWEAlgorithm.ECDH_ES, EncryptionMethod.A256GCM).ephemeralPublicKey(ephemeralKeyPair.toPublicJWK()).build(); + var payload = new Payload(Map.of(JWE_PAYLOAD_KEY_FIELD, encodedVaultKey)); + var jwe = new JWEObject(header, payload); + jwe.encrypt(new ECDHEncrypter(userKey)); + return jwe; + } catch (JOSEException e) { + throw new RuntimeException(e); } } diff --git a/src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java b/src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java index a2e46cc28..7ff9df83f 100644 --- a/src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java +++ b/src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java @@ -1,6 +1,7 @@ package org.cryptomator.ui.keyloading.hub; import com.nimbusds.jose.JWEObject; +import org.cryptomator.cryptolib.api.Masterkey; import org.cryptomator.cryptolib.api.MasterkeyLoadingFailedException; import org.cryptomator.cryptolib.common.P384KeyPair; import org.junit.jupiter.api.Assertions; @@ -140,4 +141,31 @@ public class JWEHelperTest { Assertions.assertThrows(MasterkeyLoadingFailedException.class, () -> JWEHelper.decryptVaultKey(jwe, privateKey)); } + @Test + @DisplayName("decrypt(encrypt(vaultKey, userPublicKey), userPrivateKey) == vaultKey") + public void testEncryptAndDecryptVaultKey() { + var keyBytes = new byte[64]; + Arrays.fill(keyBytes, 0, 32, (byte) 0x55); + Arrays.fill(keyBytes, 32, 64, (byte) 0x77); + var vaultKey = new Masterkey(keyBytes); + var userKey = P384KeyPair.generate(); + + var encrypted = JWEHelper.encryptVaultKey(vaultKey, userKey.getPublic()); + var decrypted = JWEHelper.decryptVaultKey(encrypted, userKey.getPrivate()); + + Assertions.assertArrayEquals(keyBytes, decrypted.getEncoded()); + } + + @Test + @DisplayName("decrypt(encrypt(userKey, devicePublicKey), devicePrivateKey) == userKey") + public void testEncryptAndDecryptUserKey() { + var userKey = P384KeyPair.generate(); + var deviceKey = P384KeyPair.generate(); + + var encrypted = JWEHelper.encryptUserKey(userKey.getPrivate(), deviceKey.getPublic()); + var decrypted = JWEHelper.decryptUserKey(encrypted, deviceKey.getPrivate()); + + Assertions.assertArrayEquals(userKey.getPrivate().getEncoded(), decrypted.getEncoded()); + } + } \ No newline at end of file From c5bb8a131d7d727319ade9691242684f83032156 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 20 Jan 2024 13:15:33 +0100 Subject: [PATCH 6/9] dedup --- .../ui/keyloading/hub/JWEHelper.java | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java b/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java index 313ec48c9..41bb6902a 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java @@ -18,6 +18,7 @@ import org.cryptomator.cryptolib.api.MasterkeyLoadingFailedException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.security.Key; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.interfaces.ECPrivateKey; @@ -39,18 +40,7 @@ class JWEHelper { private JWEHelper() {} public static JWEObject encryptUserKey(ECPrivateKey userKey, ECPublicKey deviceKey) { - try { - var encodedUserKey = Base64.getEncoder().encodeToString(userKey.getEncoded()); - var keyGen = new ECKeyGenerator(Curve.P_384); - var ephemeralKeyPair = keyGen.generate(); - var header = new JWEHeader.Builder(JWEAlgorithm.ECDH_ES, EncryptionMethod.A256GCM).ephemeralPublicKey(ephemeralKeyPair.toPublicJWK()).build(); - var payload = new Payload(Map.of(JWE_PAYLOAD_KEY_FIELD, encodedUserKey)); - var jwe = new JWEObject(header, payload); - jwe.encrypt(new ECDHEncrypter(deviceKey)); - return jwe; - } catch (JOSEException e) { - throw new RuntimeException(e); - } + return encryptKey(userKey, deviceKey); } public static ECPrivateKey decryptUserKey(JWEObject jwe, String setupCode) throws InvalidJweKeyException { @@ -118,8 +108,12 @@ class JWEHelper { } public static JWEObject encryptVaultKey(Masterkey vaultKey, ECPublicKey userKey) { + return encryptKey(vaultKey, userKey); + } + + private static JWEObject encryptKey(Key key, ECPublicKey userKey) { try { - var encodedVaultKey = Base64.getEncoder().encodeToString(vaultKey.getEncoded()); + var encodedVaultKey = Base64.getEncoder().encodeToString(key.getEncoded()); var keyGen = new ECKeyGenerator(Curve.P_384); var ephemeralKeyPair = keyGen.generate(); var header = new JWEHeader.Builder(JWEAlgorithm.ECDH_ES, EncryptionMethod.A256GCM).ephemeralPublicKey(ephemeralKeyPair.toPublicJWK()).build(); From def64aa2acc58eba4a5ca6516401e53bd7276ba2 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 20 Jan 2024 13:25:57 +0100 Subject: [PATCH 7/9] added tests --- .../ui/keyloading/hub/JWEHelperTest.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java b/src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java index 7ff9df83f..42147110a 100644 --- a/src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java +++ b/src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java @@ -5,7 +5,9 @@ import org.cryptomator.cryptolib.api.Masterkey; import org.cryptomator.cryptolib.api.MasterkeyLoadingFailedException; import org.cryptomator.cryptolib.common.P384KeyPair; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -30,6 +32,35 @@ public class JWEHelperTest { private static final String PRIV_KEY = "ME8CAQAwEAYHKoZIzj0CAQYFK4EEACIEODA2AgEBBDEA6QybmBitf94veD5aCLr7nlkF5EZpaXHCfq1AXm57AKQyGOjTDAF9EQB28fMywTDQ"; private static final String PUB_KEY = "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERxQR+NRN6Wga01370uBBzr2NHDbKIC56tPUEq2HX64RhITGhii8Zzbkb1HnRmdF0aq6uqmUy4jUhuxnKxsv59A6JeK7Unn+mpmm3pQAygjoGc9wrvoH4HWJSQYUlsXDu"; + + @Nested + @DisplayName("DER decoding") + public class Decoders { + + private static P384KeyPair keyPair; + + @BeforeAll + public static void setup() throws InvalidKeySpecException { + keyPair = P384KeyPair.create(new X509EncodedKeySpec(Base64.getDecoder().decode(DEVICE_PUB_KEY)), new PKCS8EncodedKeySpec(Base64.getDecoder().decode(DEVICE_PRIV_KEY))); + } + + @Test + @DisplayName("decodeECPublicKey") + public void testDecodeECPublicKey() { + var decodedPublicKey = JWEHelper.decodeECPublicKey(Base64.getDecoder().decode(DEVICE_PUB_KEY)); + + Assertions.assertArrayEquals(keyPair.getPublic().getEncoded(), decodedPublicKey.getEncoded()); + } + + @Test + @DisplayName("decodeECPrivateKey") + public void testDecodeECPrivateKey() { + var decodedPrivateKey = JWEHelper.decodeECPrivateKey(Base64.getDecoder().decode(DEVICE_PRIV_KEY)); + + Assertions.assertArrayEquals(keyPair.getPrivate().getEncoded(), decodedPrivateKey.getEncoded()); + } + } + @Test @DisplayName("decryptUserKey with device key") public void testDecryptUserKeyECDHES() throws ParseException, InvalidKeySpecException { From 693299a5d7fa385269f08d995c083e70430fd71a Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 20 Jan 2024 13:28:56 +0100 Subject: [PATCH 8/9] first draft of legacy device migration actual migration still missing due to API discussion --- .../hub/RegisterDeviceController.java | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java b/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java index f604cd489..11c4a2843 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java @@ -31,15 +31,19 @@ import javafx.scene.control.TextField; import javafx.stage.Stage; import javafx.stage.WindowEvent; import java.io.IOException; +import java.io.InterruptedIOException; +import java.io.UncheckedIOException; import java.net.InetAddress; -import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.security.interfaces.ECPublicKey; import java.text.ParseException; import java.time.Duration; import java.time.Instant; +import java.util.Base64; +import java.util.List; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -126,10 +130,12 @@ public class RegisterDeviceController implements FxController { } }).thenApply(user -> { try { - assert user.privateKey != null; // api/vaults/{v}/user-tokens/me would have returned 403, if user wasn't fully set up yet + assert user.privateKey != null && user.publicKey != null; // api/vaults/{v}/user-tokens/me would have returned 403, if user wasn't fully set up yet + var userPublicKey = JWEHelper.decodeECPublicKey(Base64.getDecoder().decode(user.publicKey)); + migrateLegacyDevices(userPublicKey); // TODO: remove eventually, when most users have migrated to Hub 1.3.x or newer var userKey = JWEHelper.decryptUserKey(JWEObject.parse(user.privateKey), setupCodeField.getText()); return JWEHelper.encryptUserKey(userKey, deviceKeyPair.getPublic()); - } catch (ParseException e) { + } catch (ParseException | JWEHelper.KeyDecodeFailedException e) { throw new RuntimeException("Server answered with unparsable user key", e); } }).thenCompose(jwe -> { @@ -154,6 +160,43 @@ public class RegisterDeviceController implements FxController { }, Platform::runLater); } + private void migrateLegacyDevices(ECPublicKey userPublicKey) { + try { + var accessibleVaultsUri = hubConfig.URIs.API."vaults/accessible"; + var getAccessibleDevicesReq = HttpRequest.newBuilder(accessibleVaultsUri).GET().timeout(REQ_TIMEOUT).header("Authorization", "Bearer " + bearerToken).build(); + var getAccessibleDevicesRes = httpClient.send(getAccessibleDevicesReq, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (getAccessibleDevicesRes.statusCode() != 200) { + throw new IOException(STR."Unexpected response from GET \{getAccessibleDevicesReq.uri()}: \{getAccessibleDevicesRes.statusCode()}"); + } + List vaults = JSON.readerForListOf(VaultDto.class).readValue(getAccessibleDevicesRes.body()); + for (var vault : vaults) { + LOG.debug("Attempt to migrate legacy access token for vault: {}...", vault.name); + var legacyAccessTokenUri = hubConfig.URIs.API."vaults/\{vault.id}/keys/\{deviceId}"; + var getLegacyAccessTokenReq = HttpRequest.newBuilder(legacyAccessTokenUri).GET().timeout(REQ_TIMEOUT).header("Authorization", "Bearer " + bearerToken).build(); + var getLegacyAccessTokenRes = httpClient.send(getLegacyAccessTokenReq, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (getLegacyAccessTokenRes.statusCode() == 200) { + migrateLegacyDevice(userPublicKey, vault, getLegacyAccessTokenRes.body()); + } + } + } catch (IOException | JWEHelper.KeyDecodeFailedException e) { + // log and ignore: this is merely a best-effort attempt of migrating legacy devices. Failure is uncritical as this is merely a convenience feature. + LOG.error("Legacy Device Migration failed.", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new UncheckedIOException(new InterruptedIOException("Legacy Device Migration interrupted")); + } + } + + private void migrateLegacyDevice(ECPublicKey userPublicKey, VaultDto vault, String legacyAccessToken) { + try (var vaultKey = JWEHelper.decryptVaultKey(JWEObject.parse(legacyAccessToken), deviceKeyPair.getPrivate())) { + var newToken = JWEHelper.encryptVaultKey(vaultKey, userPublicKey).serialize(); + // TODO: send new access token to backend + LOG.info("POST /api/vaults/{}/access-token {}", vault.id, newToken); + } catch (ParseException e) { + LOG.warn("Failed to parse legacy access token for vault {}. Skipping migration.", vault.name); + } + } + private UserDto fromJson(String json) { try { return JSON.reader().readValue(json, UserDto.class); @@ -216,6 +259,9 @@ public class RegisterDeviceController implements FxController { @JsonIgnoreProperties(ignoreUnknown = true) private record UserDto(String id, String name, String publicKey, String privateKey, String setupCode) {} + @JsonIgnoreProperties(ignoreUnknown = true) + private record VaultDto(String id, String name) {} + private record CreateDeviceDto(@JsonProperty(required = true) String id, // @JsonProperty(required = true) String name, // @JsonProperty(required = true) String publicKey, // From d66cfe0e7c460e8ef42454b11ee5a3b6b2f9f693 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Fri, 26 Jan 2024 16:48:08 +0100 Subject: [PATCH 9/9] adjust to new migration API --- .../hub/RegisterDeviceController.java | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java b/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java index 11c4a2843..b00d49874 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java @@ -43,12 +43,13 @@ import java.text.ParseException; import java.time.Duration; import java.time.Instant; import java.util.Base64; -import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; @KeyLoadingScoped public class RegisterDeviceController implements FxController { @@ -162,23 +163,36 @@ public class RegisterDeviceController implements FxController { private void migrateLegacyDevices(ECPublicKey userPublicKey) { try { - var accessibleVaultsUri = hubConfig.URIs.API."vaults/accessible"; - var getAccessibleDevicesReq = HttpRequest.newBuilder(accessibleVaultsUri).GET().timeout(REQ_TIMEOUT).header("Authorization", "Bearer " + bearerToken).build(); - var getAccessibleDevicesRes = httpClient.send(getAccessibleDevicesReq, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (getAccessibleDevicesRes.statusCode() != 200) { - throw new IOException(STR."Unexpected response from GET \{getAccessibleDevicesReq.uri()}: \{getAccessibleDevicesRes.statusCode()}"); + // GET legacy access tokens + var getUri = hubConfig.URIs.API."devices/\{deviceId}/legacy-access-tokens"; + var getReq = HttpRequest.newBuilder(getUri).GET().timeout(REQ_TIMEOUT).header("Authorization", "Bearer " + bearerToken).build(); + var getRes = httpClient.send(getReq, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (getRes.statusCode() != 200) { + LOG.debug("GET {} resulted in status code {}. Skipping migration.", getUri, getRes.statusCode()); + return; } - List vaults = JSON.readerForListOf(VaultDto.class).readValue(getAccessibleDevicesRes.body()); - for (var vault : vaults) { - LOG.debug("Attempt to migrate legacy access token for vault: {}...", vault.name); - var legacyAccessTokenUri = hubConfig.URIs.API."vaults/\{vault.id}/keys/\{deviceId}"; - var getLegacyAccessTokenReq = HttpRequest.newBuilder(legacyAccessTokenUri).GET().timeout(REQ_TIMEOUT).header("Authorization", "Bearer " + bearerToken).build(); - var getLegacyAccessTokenRes = httpClient.send(getLegacyAccessTokenReq, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (getLegacyAccessTokenRes.statusCode() == 200) { - migrateLegacyDevice(userPublicKey, vault, getLegacyAccessTokenRes.body()); + Map legacyAccessTokens = JSON.readerForMapOf(String.class).readValue(getRes.body()); + if (legacyAccessTokens.isEmpty()) { + return; // no migration required + } + + // POST new access tokens + Map newAccessTokens = legacyAccessTokens.entrySet().stream().>mapMulti((entry, consumer) -> { + try (var vaultKey = JWEHelper.decryptVaultKey(JWEObject.parse(entry.getValue()), deviceKeyPair.getPrivate())) { + var newAccessToken = JWEHelper.encryptVaultKey(vaultKey, userPublicKey).serialize(); + consumer.accept(Map.entry(entry.getKey(), newAccessToken)); + } catch (ParseException | JWEHelper.InvalidJweKeyException e) { + LOG.warn("Failed to decrypt legacy access token for vault {}. Skipping migration.", entry.getKey()); } + }).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + var postUri = hubConfig.URIs.API."users/me/access-tokens"; + var postBody = JSON.writer().writeValueAsString(newAccessTokens); + var postReq = HttpRequest.newBuilder(postUri).POST(HttpRequest.BodyPublishers.ofString(postBody)).timeout(REQ_TIMEOUT).header("Authorization", "Bearer " + bearerToken).build(); + var postRes = httpClient.send(postReq, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (postRes.statusCode() != 200) { + throw new IOException(STR."Unexpected response from POST \{postUri}: \{postRes.statusCode()}"); } - } catch (IOException | JWEHelper.KeyDecodeFailedException e) { + } catch (IOException e) { // log and ignore: this is merely a best-effort attempt of migrating legacy devices. Failure is uncritical as this is merely a convenience feature. LOG.error("Legacy Device Migration failed.", e); } catch (InterruptedException e) { @@ -187,16 +201,6 @@ public class RegisterDeviceController implements FxController { } } - private void migrateLegacyDevice(ECPublicKey userPublicKey, VaultDto vault, String legacyAccessToken) { - try (var vaultKey = JWEHelper.decryptVaultKey(JWEObject.parse(legacyAccessToken), deviceKeyPair.getPrivate())) { - var newToken = JWEHelper.encryptVaultKey(vaultKey, userPublicKey).serialize(); - // TODO: send new access token to backend - LOG.info("POST /api/vaults/{}/access-token {}", vault.id, newToken); - } catch (ParseException e) { - LOG.warn("Failed to parse legacy access token for vault {}. Skipping migration.", vault.name); - } - } - private UserDto fromJson(String json) { try { return JSON.reader().readValue(json, UserDto.class); @@ -259,9 +263,6 @@ public class RegisterDeviceController implements FxController { @JsonIgnoreProperties(ignoreUnknown = true) private record UserDto(String id, String name, String publicKey, String privateKey, String setupCode) {} - @JsonIgnoreProperties(ignoreUnknown = true) - private record VaultDto(String id, String name) {} - private record CreateDeviceDto(@JsonProperty(required = true) String id, // @JsonProperty(required = true) String name, // @JsonProperty(required = true) String publicKey, //