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/JWEHelper.java b/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java index 2333051be..41bb6902a 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; @@ -13,19 +12,20 @@ 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.Key; import java.security.KeyFactory; -import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; 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; @@ -37,26 +37,16 @@ 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()); - 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 { 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); } @@ -65,17 +55,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 { @@ -84,8 +80,49 @@ 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); + } + } + + /** + * 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) { + return encryptKey(vaultKey, userKey); + } + + private static JWEObject encryptKey(Key key, ECPublicKey userKey) { + try { + 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(); + 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); } } @@ -108,12 +145,12 @@ 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); } - } catch (IllegalArgumentException e) { + } catch (IllegalArgumentException | KeyDecodeFailedException e) { LOG.error("Unexpected JWE payload: {}", jwe.getPayload()); throw new MasterkeyLoadingFailedException("Unexpected JWE payload", e); } finally { @@ -127,4 +164,11 @@ class JWEHelper { super("Invalid key", cause); } } + + public static class KeyDecodeFailedException extends CryptoException { + + public KeyDecodeFailedException(Throwable cause) { + super("Malformed key", cause); + } + } } 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 08af2492a..b00d49874 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java @@ -31,19 +31,25 @@ 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.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.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 { @@ -108,9 +114,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(hubConfig.URIs.API."users/me") // .GET() // .timeout(REQ_TIMEOUT) // .header("Authorization", "Bearer " + bearerToken) // @@ -126,17 +131,19 @@ 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 -> { 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 = hubConfig.URIs.API."devices/\{deviceId}"; var putDeviceReq = HttpRequest.newBuilder(deviceUri) // .PUT(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) // .timeout(REQ_TIMEOUT) // @@ -154,6 +161,46 @@ public class RegisterDeviceController implements FxController { }, Platform::runLater); } + private void migrateLegacyDevices(ECPublicKey userPublicKey) { + try { + // 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; + } + 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 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 UserDto fromJson(String json) { try { return JSON.reader().readValue(json, UserDto.class); 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..42147110a 100644 --- a/src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java +++ b/src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java @@ -1,10 +1,13 @@ 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; +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; @@ -29,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 { @@ -140,4 +172,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