Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hub support #560

Merged
merged 15 commits into from
Dec 8, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,20 @@
import org.cryptomator.domain.UnverifiedVaultConfig;
import org.cryptomator.domain.Vault;
import org.cryptomator.domain.exception.BackendException;
import org.cryptomator.domain.exception.FatalBackendException;
import org.cryptomator.domain.repository.CloudContentRepository;
import org.cryptomator.domain.usecases.ProgressAware;
import org.cryptomator.domain.usecases.cloud.Flag;
import org.cryptomator.domain.usecases.vault.UnlockToken;

import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;

import javax.inject.Inject;
import javax.inject.Singleton;

import static org.cryptomator.data.cloud.crypto.CryptoConstants.HUB_SCHEME;
import static org.cryptomator.data.cloud.crypto.CryptoConstants.MASTERKEY_SCHEME;
import static org.cryptomator.data.cloud.crypto.CryptoConstants.VAULT_FILE_NAME;
import static org.cryptomator.domain.Vault.aCopyOf;

Expand All @@ -31,7 +31,6 @@ public class CryptoCloudFactory {

private final CloudContentRepository<Cloud, CloudNode, CloudFolder, CloudFile> cloudContentRepository;
private final CryptoCloudContentRepositoryFactory cryptoCloudContentRepositoryFactory;
private final SecureRandom secureRandom = new SecureRandom();

@Inject
public CryptoCloudFactory(CloudContentRepository/*<Cloud, CloudNode, CloudFolder, CloudFile>*/ cloudContentRepository, //
Expand Down Expand Up @@ -99,14 +98,20 @@ private CryptoCloudProvider cryptoCloudProvider(UnverifiedVaultConfig unverified

private CryptoCloudProvider cryptoCloudProvider(Optional<UnverifiedVaultConfig> unverifiedVaultConfigOptional) {
if (unverifiedVaultConfigOptional.isPresent()) {
if (MASTERKEY_SCHEME.equals(unverifiedVaultConfigOptional.get().getKeyId().getScheme())) {
return new MasterkeyCryptoCloudProvider(cloudContentRepository, cryptoCloudContentRepositoryFactory, secureRandom);
} else if (unverifiedVaultConfigOptional.get().getKeyId().getScheme().startsWith(HUB_SCHEME)) {
return new HubkeyCryptoCloudProvider(cryptoCloudContentRepositoryFactory, secureRandom);
}
throw new IllegalStateException(String.format("Provider with scheme %s not supported", unverifiedVaultConfigOptional.get().getKeyId().getScheme()));
return switch (unverifiedVaultConfigOptional.get().keyLoadingStrategy()) {
case MASTERKEY -> new MasterkeyCryptoCloudProvider(cloudContentRepository, cryptoCloudContentRepositoryFactory, secureRandom());
case HUB -> new HubkeyCryptoCloudProvider(cryptoCloudContentRepositoryFactory, secureRandom());
};
} else {
return new MasterkeyCryptoCloudProvider(cloudContentRepository, cryptoCloudContentRepositoryFactory, secureRandom);
return new MasterkeyCryptoCloudProvider(cloudContentRepository, cryptoCloudContentRepositoryFactory, secureRandom());
SailReal marked this conversation as resolved.
Show resolved Hide resolved
}
}

private SecureRandom secureRandom() {
try {
return SecureRandom.getInstanceStrong();
} catch (NoSuchAlgorithmException e) {
throw new FatalBackendException("A strong algorithm must exist in every Java platform.", e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ object CryptoConstants {

const val MASTERKEY_SCHEME = "masterkeyfile"
const val MASTERKEY_FILE_NAME = "masterkey.cryptomator"
const val HUB_SCHEME = "hub+"
const val HUB_REDIRECT_URL = "org.cryptomator.android:/hub/auth"
SailReal marked this conversation as resolved.
Show resolved Hide resolved
const val ROOT_DIR_ID = ""
const val DATA_DIR_NAME = "d"
Expand All @@ -17,6 +16,7 @@ object CryptoConstants {
const val MAX_VAULT_VERSION_WITHOUT_VAULT_CONFIG = 7
const val VERSION_WITH_NORMALIZED_PASSWORDS = 6
const val MIN_VAULT_VERSION = 5
const val MIN_HUB_VAULT_VERSION = 8
const val DEFAULT_MAX_FILE_NAME = 220
const val DIR_ID_FILE = "dirid.c9r"
val PEPPER = ByteArray(0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,17 @@ class HubkeyCryptoCloudProvider(

@Throws(BackendException::class)
override fun create(location: CloudFolder, password: CharSequence) {
throw IllegalStateException("Hub can not create vaults from within the app")
throw UnsupportedOperationException("This app can not (yet) create Hub vaults")
}

@Throws(BackendException::class)
override fun unlock(vault: Vault, unverifiedVaultConfig: Optional<UnverifiedVaultConfig>, password: CharSequence, cancelledFlag: Flag): Vault {
throw IllegalStateException("Hub can not unlock vaults using password")
throw UnsupportedOperationException("Hub vaults do not support password based unlock")
}

@Throws(BackendException::class)
override fun unlock(token: UnlockToken, unverifiedVaultConfig: Optional<UnverifiedVaultConfig>, password: CharSequence, cancelledFlag: Flag): Vault {
throw IllegalStateException("Hub can not unlock vaults using password")
throw UnsupportedOperationException("Hub vaults do not support password based unlock")
}

override fun unlock(vault: Vault, unverifiedVaultConfig: UnverifiedVaultConfig, vaultKeyJwe: String, userKeyJwe: String, cancelledFlag: Flag): Vault {
Expand Down Expand Up @@ -67,41 +67,35 @@ class HubkeyCryptoCloudProvider(
}

@Throws(BackendException::class)
override fun createUnlockToken(vault: Vault, unverifiedVaultConfig: Optional<UnverifiedVaultConfig>): UnlockTokenImpl {
throw IllegalStateException("Hub can not unlock vaults using password")
override fun createUnlockToken(vault: Vault, unverifiedVaultConfig: Optional<UnverifiedVaultConfig>): UnlockToken {
throw UnsupportedOperationException("Hub vaults do not support password based unlock")
}

// Visible for testing
fun cryptorFor(keyFile: Masterkey, vaultCipherCombo: CryptorProvider.Scheme): Cryptor {
return CryptorProvider.forScheme(vaultCipherCombo).provide(keyFile, secureRandom)
fun cryptorFor(masterkey: Masterkey, vaultCipherCombo: CryptorProvider.Scheme): Cryptor {
return CryptorProvider.forScheme(vaultCipherCombo).provide(masterkey, secureRandom)
}

@Throws(BackendException::class)
override fun isVaultPasswordValid(vault: Vault, unverifiedVaultConfig: Optional<UnverifiedVaultConfig>, password: CharSequence): Boolean {
throw IllegalStateException("Hub can not unlock vaults using password")
throw UnsupportedOperationException("Hub vaults do not support password based unlock")
}

override fun lock(vault: Vault) {
cryptoCloudContentRepositoryFactory.deregisterCryptor(vault)
}

private fun assertVaultVersionIsSupported(version: Int) {
if (version < CryptoConstants.MIN_VAULT_VERSION) {
throw UnsupportedVaultFormatException(version, CryptoConstants.MIN_VAULT_VERSION)
if (version < CryptoConstants.MIN_HUB_VAULT_VERSION) {
throw UnsupportedVaultFormatException(version, CryptoConstants.MIN_HUB_VAULT_VERSION)
} else if (version > CryptoConstants.MAX_VAULT_VERSION) {
throw UnsupportedVaultFormatException(version, CryptoConstants.MAX_VAULT_VERSION)
}
}

@Throws(BackendException::class)
override fun changePassword(vault: Vault, unverifiedVaultConfig: Optional<UnverifiedVaultConfig>, oldPassword: String, newPassword: String) {
throw IllegalStateException("Hub can not unlock vaults using password")
throw UnsupportedOperationException("Hub vaults do not support password based unlock")
}

class UnlockTokenImpl(private val vault: Vault) : UnlockToken {

override fun getVault(): Vault {
return vault
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ class MasterkeyCryptoCloudProvider(
}

override fun unlock(vault: Vault, unverifiedVaultConfig: UnverifiedVaultConfig, vaultKeyJwe: String, userKeyJwe: String, cancelledFlag: Flag): Vault {
throw IllegalStateException("Password based vaults do not support hub unlock")
throw UnsupportedOperationException("Password based vaults do not support hub unlock")
}

@Throws(BackendException::class)
Expand Down Expand Up @@ -157,7 +157,7 @@ class MasterkeyCryptoCloudProvider(
@Throws(BackendException::class)
private fun createUnlockToken(vault: Vault, location: CloudFile): UnlockTokenImpl {
val keyFileData = readKeyFileData(location)
return UnlockTokenImpl(vault, keyFileData)
return UnlockTokenImpl(vault, secureRandom, keyFileData)
}

@Throws(BackendException::class)
Expand Down Expand Up @@ -290,15 +290,15 @@ class MasterkeyCryptoCloudProvider(
}
}

class UnlockTokenImpl(private val vault: Vault, val keyFileData: ByteArray) : UnlockToken {
class UnlockTokenImpl(private val vault: Vault, private val secureRandom: SecureRandom, val keyFileData: ByteArray) : UnlockToken {

override fun getVault(): Vault {
return vault
}

@Throws(IOException::class)
fun getKeyFile(password: CharSequence?): Masterkey {
return MasterkeyFileAccess(CryptoConstants.PEPPER, SecureRandom()).load(ByteArrayInputStream(keyFileData), password)
return MasterkeyFileAccess(CryptoConstants.PEPPER, secureRandom).load(ByteArrayInputStream(keyFileData), password)
SailReal marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
29 changes: 18 additions & 11 deletions data/src/main/java/org/cryptomator/data/cloud/crypto/VaultConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.auth0.jwt.exceptions.JWTVerificationException
import com.auth0.jwt.exceptions.SignatureVerificationException
import com.auth0.jwt.interfaces.DecodedJWT
import org.cryptomator.cryptolib.api.CryptorProvider
import org.cryptomator.domain.KeyLoadingStrategy
import org.cryptomator.domain.UnverifiedHubVaultConfig
import org.cryptomator.domain.UnverifiedVaultConfig
import org.cryptomator.domain.exception.vaultconfig.VaultConfigLoadException
Expand Down Expand Up @@ -88,22 +89,28 @@ class VaultConfig private constructor(builder: VaultConfigBuilder) {
} catch (e: IllegalArgumentException) {
throw VaultConfigLoadException("Invalid 'keyId' in JWT: ${e.message}", e)
}
if (keyId.scheme.startsWith(CryptoConstants.HUB_SCHEME)) {
val hubClaim = unverifiedJwt.getHeaderClaim("hub").asMap()
val clientId = hubClaim["clientId"] as? String ?: throw VaultConfigLoadException("Missing or invalid 'clientId' claim in JWT header")
val authEndpoint = parseUri(hubClaim, "authEndpoint")
val tokenEndpoint = parseUri(hubClaim, "tokenEndpoint")
val apiBaseUrl = parseUri(hubClaim, "apiBaseUrl")
return UnverifiedHubVaultConfig(token, keyId, vaultFormat, clientId, authEndpoint, tokenEndpoint, apiBaseUrl)
} else {
return UnverifiedVaultConfig(token, keyId, vaultFormat)
return when (KeyLoadingStrategy.fromKeyId(keyId)) {
KeyLoadingStrategy.MASTERKEY -> UnverifiedVaultConfig(token, keyId, vaultFormat)
KeyLoadingStrategy.HUB -> {
val hubClaim = unverifiedJwt.getHeaderClaim("hub").asMap()
val clientId = hubClaim["clientId"] as? String ?: throw VaultConfigLoadException("Missing or invalid 'clientId' claim in JWT header")
val authEndpoint = parseUri(hubClaim, "authEndpoint")
val tokenEndpoint = parseUri(hubClaim, "tokenEndpoint")
val apiBaseUrl = parseUri(hubClaim, "apiBaseUrl", ensureTrailingSlash = true)
return UnverifiedHubVaultConfig(token, keyId, vaultFormat, clientId, authEndpoint, tokenEndpoint, apiBaseUrl)
}
}
}

private fun parseUri(uriValue: Map<String, Any>, fieldName: String): URI {
private fun parseUri(uriValue: Map<String, Any>, fieldName: String, ensureTrailingSlash: Boolean = false): URI {
val uriString = uriValue[fieldName] as? String ?: throw VaultConfigLoadException("Missing or invalid '$fieldName' claim in JWT header")
val adjustedUriString = if (ensureTrailingSlash && !uriString.endsWith("/")) {
"$uriString/"
} else {
uriString
}
return try {
URI.create(uriString)
URI.create(adjustedUriString)
} catch (e: IllegalArgumentException) {
throw VaultConfigLoadException("Invalid '$fieldName' URI: ${e.message}", e)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,22 @@ public UserDto getUser(UnverifiedHubVaultConfig unverifiedHubVaultConfig, String
.url(unverifiedHubVaultConfig.getApiBaseUrl() + "users/me") //
SailReal marked this conversation as resolved.
Show resolved Hide resolved
.build();
try (var response = httpClient.newCall(request).execute()) {
if (response.isSuccessful() && response.body() != null) {
JSONObject jsonObject = new JSONObject(response.body().string());
return new UserDto(jsonObject.getString("id"), jsonObject.getString("name"), jsonObject.getString("publicKey"), jsonObject.getString("privateKey"), jsonObject.getString("setupCode"));
if (response.code() == HttpURLConnection.HTTP_OK) {
if (response.body() != null) {
JSONObject jsonObject = new JSONObject(response.body().string());
return new UserDto( //
jsonObject.getString("id"), //
jsonObject.getString("name"), //
jsonObject.getString("publicKey"), //
jsonObject.getString("privateKey"), //
jsonObject.getString("setupCode") //
);
} else {
throw new FatalBackendException("Failed to load user, response code good but no body");
}
} else {
throw new FatalBackendException("Failed to load user with response code " + response.code());
}
throw new FatalBackendException("Failed to load user, bad response code " + response.code());
} catch (IOException | JSONException e) {
throw new FatalBackendException("Failed to load user", e);
}
Expand Down Expand Up @@ -133,11 +144,11 @@ public DeviceDto getDevice(UnverifiedHubVaultConfig unverifiedHubVaultConfig, St
@Override
public void createDevice(UnverifiedHubVaultConfig unverifiedHubVaultConfig, String accessToken, String deviceName, String setupCode, String userPrivateKey) throws BackendException {
var deviceId = hubDeviceCryptor.getDeviceId();
var publicKey = BaseEncoding.base64().encode(hubDeviceCryptor.getDevicePublicKey().getEncoded());

var devicePublicKey = hubDeviceCryptor.getDevicePublicKeyEncoded();
var publicKey = BaseEncoding.base64().encode(devicePublicKey);
JWEObject encryptedUserKey;
try {
encryptedUserKey = hubDeviceCryptor.encryptUserKey(JWEObject.parse(userPrivateKey), setupCode);
encryptedUserKey = hubDeviceCryptor.reEncryptUserKey(JWEObject.parse(userPrivateKey), setupCode);
} catch (HubDeviceCryptor.InvalidJweKeyException e) {
throw new HubInvalidSetupCodeException(e);
} catch (ParseException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ internal class MasterkeyCryptoCloudProviderTest {
whenever(vault.path).thenReturn("/foo")
whenever(vault.isUnlocked).thenReturn(true)

val unlockToken = UnlockTokenImpl(vault, masterkeyV7.toByteArray(StandardCharsets.UTF_8))
val unlockToken = UnlockTokenImpl(vault, secureRandom, masterkeyV7.toByteArray(StandardCharsets.UTF_8))
val unverifiedVaultConfig = UnverifiedVaultConfig(vaultConfig, URI.create(String.format("%s:%s", CryptoConstants.MASTERKEY_SCHEME, CryptoConstants.MASTERKEY_FILE_NAME)), CryptoConstants.MAX_VAULT_VERSION)
val result: Vault = inTest.unlock(unlockToken, Optional.of(unverifiedVaultConfig), "foo") { false }

Expand All @@ -192,7 +192,7 @@ internal class MasterkeyCryptoCloudProviderTest {
whenever(vault.path).thenReturn("/foo")
whenever(vault.isUnlocked).thenReturn(true)

val unlockToken = UnlockTokenImpl(vault, masterkeyV7.toByteArray(StandardCharsets.UTF_8))
val unlockToken = UnlockTokenImpl(vault, secureRandom, masterkeyV7.toByteArray(StandardCharsets.UTF_8))
val result = inTest.unlock(unlockToken, Optional.absent(), "foo", { false })

MatcherAssert.assertThat(result.isUnlocked, CoreMatchers.`is`(true))
Expand All @@ -206,7 +206,7 @@ internal class MasterkeyCryptoCloudProviderTest {
@Test
@DisplayName("unlockLegacyUsingNewVault(\"foo\")")
fun testUnlockLegacyVaultUsingVaultFormat8() {
val unlockToken: UnlockToken = UnlockTokenImpl(vault, masterkeyV8.toByteArray(StandardCharsets.UTF_8))
val unlockToken: UnlockToken = UnlockTokenImpl(vault, secureRandom, masterkeyV8.toByteArray(StandardCharsets.UTF_8))
Assertions.assertThrows(MissingVaultConfigFileException::class.java) { inTest.unlock(unlockToken, Optional.absent(), "foo", { false }) }
}

Expand Down Expand Up @@ -302,15 +302,15 @@ internal class MasterkeyCryptoCloudProviderTest {
if (legacy) {
MatcherAssert.assertThat(testVaultPasswordVault(masterkeyV7, Optional.absent(), password), CoreMatchers.`is`(true))

val unlockToken = UnlockTokenImpl(vault, masterkeyV7.toByteArray(StandardCharsets.UTF_8))
val unlockToken = UnlockTokenImpl(vault, secureRandom, masterkeyV7.toByteArray(StandardCharsets.UTF_8))

Mockito.verify(inTest).cryptorFor(unlockToken.getKeyFile(password), CryptorProvider.Scheme.SIV_CTRMAC)
} else {
val unverifiedVaultConfig = UnverifiedVaultConfig(vaultConfig, URI.create(String.format("%s:%s", CryptoConstants.MASTERKEY_SCHEME, CryptoConstants.MASTERKEY_FILE_NAME)), CryptoConstants.MAX_VAULT_VERSION)

MatcherAssert.assertThat(testVaultPasswordVault(masterkeyV8, Optional.of(unverifiedVaultConfig), password), CoreMatchers.`is`(true))

val unlockToken = UnlockTokenImpl(vault, masterkeyV8.toByteArray(StandardCharsets.UTF_8))
val unlockToken = UnlockTokenImpl(vault, secureRandom, masterkeyV8.toByteArray(StandardCharsets.UTF_8))

Mockito.verify(inTest).cryptorFor(unlockToken.getKeyFile(password), CryptorProvider.Scheme.SIV_GCM)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,19 @@ package org.cryptomator.domain
import java.io.Serializable
import java.net.URI

open class UnverifiedVaultConfig(open val jwt: String, open val keyId: URI, open val vaultFormat: Int) : Serializable
open class UnverifiedVaultConfig(open val jwt: String, open val keyId: URI, open val vaultFormat: Int) : Serializable {
fun keyLoadingStrategy(): KeyLoadingStrategy = KeyLoadingStrategy.fromKeyId(keyId)
}

enum class KeyLoadingStrategy(private val prefix: String) {
MASTERKEY("masterkey+"),
SailReal marked this conversation as resolved.
Show resolved Hide resolved
HUB("hub+http");

companion object {
fun fromKeyId(keyId: URI): KeyLoadingStrategy {
val keyIdStr = keyId.toString()
return entries.firstOrNull { keyIdStr.startsWith(it.prefix) }
?: throw IllegalArgumentException("Unsupported keyId prefix: $keyId")
JaniruTEC marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
Loading
Loading