瀏覽代碼

Merge pull request #3291 from cryptomator/feature/legacy-device-migration

Hub 1.2.x → Hub 1.3.x Legacy Device Migration
Sebastian Stenzel 1 年之前
父節點
當前提交
ee5a5c6563

+ 21 - 0
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 <code>/api/</code> base resource.
 	 *
 	 * @return <code>/api/</code> URI
 	 * @apiNote URI is guaranteed to end on <code>/</code>
+	 * @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 <code>/api/</code> endpoint of this Hub instance.
+		 */
+		public final StringTemplate.Processor<URI, RuntimeException> API = template -> {
+			var path = template.interpolate();
+			var relPath = path.startsWith("/") ? path.substring(1) : path;
+			return getApiBaseUrl().resolve(relPath);
+		};
+
+	}
 }

+ 70 - 26
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);
+		}
+	}
 }

文件差異過大導致無法顯示
+ 4 - 11
src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java


+ 52 - 5
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<String, String> legacyAccessTokens = JSON.readerForMapOf(String.class).readValue(getRes.body());
+			if (legacyAccessTokens.isEmpty()) {
+				return; // no migration required
+			}
+
+			// POST new access tokens
+			Map<String, String> newAccessTokens = legacyAccessTokens.entrySet().stream().<Map.Entry<String, String>>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);

+ 59 - 0
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());
+	}
+
 }