mirror of
https://github.com/cryptomator/cryptomator.git
synced 2026-04-29 05:42:39 -04:00
Merge pull request #3291 from cryptomator/feature/legacy-device-migration
Hub 1.2.x → Hub 1.3.x Legacy Device Migration
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ public class ReceiveKeyController implements FxController {
|
||||
private final Lazy<Scene> accountInitializationScene;
|
||||
private final Lazy<Scene> invalidLicenseScene;
|
||||
private final HttpClient httpClient;
|
||||
private final StringTemplate.Processor<URI, RuntimeException> API_BASE = this::resolveRelativeToApiBase;
|
||||
|
||||
@Inject
|
||||
public ReceiveKeyController(@KeyLoading Vault vault, ExecutorService executor, @KeyLoading Stage window, HubConfig hubConfig, @Named("deviceId") String deviceId, @Named("bearerToken") AtomicReference<String> tokenRef, CompletableFuture<ReceivedKey> result, @FxmlScene(FxmlFile.HUB_REGISTER_DEVICE) Lazy<Scene> registerDeviceScene, @FxmlScene(FxmlFile.HUB_LEGACY_REGISTER_DEVICE) Lazy<Scene> legacyRegisterDeviceScene, @FxmlScene(FxmlFile.HUB_UNAUTHORIZED_DEVICE) Lazy<Scene> unauthorizedScene, @FxmlScene(FxmlFile.HUB_REQUIRE_ACCOUNT_INIT) Lazy<Scene> accountInitializationScene, @FxmlScene(FxmlFile.HUB_INVALID_LICENSE) Lazy<Scene> 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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user