Skip to content

Commit

Permalink
Merge pull request #560 from cryptomator/feature/hub-support
Browse files Browse the repository at this point in the history
Hub support
  • Loading branch information
SailReal authored Dec 8, 2024
2 parents 5076ee7 + 342e34b commit a889842
Show file tree
Hide file tree
Showing 50 changed files with 1,848 additions and 28 deletions.
8 changes: 8 additions & 0 deletions buildsystem/dependencies.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ ext {

gsonVersion = '2.11.0'

joseJwtVersion = '9.47'

appauthVersion = '0.11.1'

okHttpVersion = '4.12.0'
okHttpDigestVersion = '3.1.0'

Expand Down Expand Up @@ -107,6 +111,7 @@ ext {
mockitoVersion = '5.12.0'
mockitoKotlinVersion = '5.3.1'
mockitoInlineVersion = '5.2.0'
mockitoAndroidVersion = '5.14.2'
hamcrestVersion = '1.3'
dexmakerVersion = '1.0'
espressoVersion = '3.4.0'
Expand Down Expand Up @@ -140,6 +145,7 @@ ext {
androidxViewpager : "androidx.viewpager:viewpager:${androidxViewpagerVersion}",
androidxSwiperefresh : "androidx.swiperefreshlayout:swiperefreshlayout:${androidxSwiperefreshVersion}",
androidxPreference : "androidx.preference:preference:${androidxPreferenceVersion}",
appauth : "net.openid:appauth:${appauthVersion}",
documentFile : "androidx.documentfile:documentfile:${androidxDocumentfileVersion}",
recyclerView : "androidx.recyclerview:recyclerview:${androidxRecyclerViewVersion}",
androidxSplashscreen : "androidx.core:core-splashscreen:${androidxSplashscreenVersion}",
Expand All @@ -163,6 +169,7 @@ ext {
gson : "com.google.code.gson:gson:${gsonVersion}",
hamcrest : "org.hamcrest:hamcrest-all:${hamcrestVersion}",
javaxAnnotation : "javax.annotation:jsr250-api:${javaxAnnotationVersion}",
joseJwt : "com.nimbusds:nimbus-jose-jwt:${joseJwtVersion}",
junit : "org.junit.jupiter:junit-jupiter:${jUnitVersion}",
junitApi : "org.junit.jupiter:junit-jupiter-api:${jUnitVersion}",
junitEngine : "org.junit.jupiter:junit-jupiter-engine:${jUnitVersion}",
Expand All @@ -172,6 +179,7 @@ ext {
mockito : "org.mockito:mockito-core:${mockitoVersion}",
mockitoInline : "org.mockito:mockito-inline:${mockitoInlineVersion}",
mockitoKotlin : "org.mockito.kotlin:mockito-kotlin:${mockitoKotlinVersion}",
mockitoAndroid : "org.mockito:mockito-android:${mockitoAndroidVersion}",
msgraph : "com.microsoft.graph:microsoft-graph:${msgraphVersion}",
msgraphAuth : "com.microsoft.identity.client:msal:${msgraphAuthVersion}",
multidex : "androidx.multidex:multidex:${multidexVersion}",
Expand Down
2 changes: 2 additions & 0 deletions data/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ dependencies {
compileOnly dependencies.javaxAnnotation
implementation dependencies.gson

implementation dependencies.joseJwt

implementation dependencies.commonsCodec

implementation dependencies.documentFile
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +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.MASTERKEY_SCHEME;
import static org.cryptomator.data.cloud.crypto.CryptoConstants.VAULT_FILE_NAME;
import static org.cryptomator.domain.Vault.aCopyOf;

Expand All @@ -30,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 All @@ -40,7 +40,7 @@ public CryptoCloudFactory(CloudContentRepository/*<Cloud, CloudNode, CloudFolder
}

public void create(CloudFolder location, CharSequence password) throws BackendException {
cryptoCloudProvider(Optional.absent()).create(location, password);
masterkeyCryptoCloudProvider().create(location, password);
}

public Cloud decryptedViewOf(Vault vault) throws BackendException {
Expand Down Expand Up @@ -68,6 +68,10 @@ public Vault unlock(UnlockToken token, Optional<UnverifiedVaultConfig> unverifie
return cryptoCloudProvider(unverifiedVaultConfig).unlock(token, unverifiedVaultConfig, password, cancelledFlag);
}

public Vault unlock(Vault vault, UnverifiedVaultConfig unverifiedVaultConfig, String vaultKeyJwe, String userKeyJwe, Flag cancelledFlag) throws BackendException {
return cryptoCloudProvider(unverifiedVaultConfig).unlock(vault, unverifiedVaultConfig, vaultKeyJwe, userKeyJwe, cancelledFlag);
}

public UnlockToken createUnlockToken(Vault vault, Optional<UnverifiedVaultConfig> unverifiedVaultConfig) throws BackendException {
return cryptoCloudProvider(unverifiedVaultConfig).createUnlockToken(vault, unverifiedVaultConfig);
}
Expand All @@ -84,14 +88,30 @@ public void changePassword(Vault vault, Optional<UnverifiedVaultConfig> unverifi
cryptoCloudProvider(unverifiedVaultConfig).changePassword(vault, unverifiedVaultConfig, oldPassword, newPassword);
}

private CryptoCloudProvider masterkeyCryptoCloudProvider() {
return cryptoCloudProvider(Optional.absent());
}

private CryptoCloudProvider cryptoCloudProvider(UnverifiedVaultConfig unverifiedVaultConfigOptional) {
return cryptoCloudProvider(Optional.of(unverifiedVaultConfigOptional));
}

private CryptoCloudProvider cryptoCloudProvider(Optional<UnverifiedVaultConfig> unverifiedVaultConfigOptional) {
if (unverifiedVaultConfigOptional.isPresent()) {
if (MASTERKEY_SCHEME.equals(unverifiedVaultConfigOptional.get().getKeyId().getScheme())) {
return new MasterkeyCryptoCloudProvider(cloudContentRepository, 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());
}
}

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 @@ -19,6 +19,8 @@ public interface CryptoCloudProvider {

Vault unlock(UnlockToken token, Optional<UnverifiedVaultConfig> unverifiedVaultConfig, CharSequence password, Flag cancelledFlag) throws BackendException;

Vault unlock(Vault vault, UnverifiedVaultConfig unverifiedVaultConfig, String vaultKeyJwe, String userKeyJwe, Flag cancelledFlag) throws BackendException;

boolean isVaultPasswordValid(Vault vault, Optional<UnverifiedVaultConfig> unverifiedVaultConfig, CharSequence password) throws BackendException;

void lock(Vault vault);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ object CryptoConstants {

const val MASTERKEY_SCHEME = "masterkeyfile"
const val MASTERKEY_FILE_NAME = "masterkey.cryptomator"
const val HUB_REDIRECT_URL = "org.cryptomator.android:/hub/auth"
const val ROOT_DIR_ID = ""
const val DATA_DIR_NAME = "d"
const val VAULT_FILE_NAME = "vault.cryptomator"
Expand All @@ -15,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
@@ -0,0 +1,101 @@
package org.cryptomator.data.cloud.crypto

import com.google.common.base.Optional
import com.nimbusds.jose.JWEObject
import org.cryptomator.cryptolib.api.Cryptor
import org.cryptomator.cryptolib.api.CryptorProvider
import org.cryptomator.cryptolib.api.Masterkey
import org.cryptomator.cryptolib.api.UnsupportedVaultFormatException
import org.cryptomator.data.cloud.crypto.VaultConfig.Companion.verify
import org.cryptomator.domain.CloudFolder
import org.cryptomator.domain.UnverifiedVaultConfig
import org.cryptomator.domain.Vault
import org.cryptomator.domain.exception.BackendException
import org.cryptomator.domain.exception.CancellationException
import org.cryptomator.domain.exception.FatalBackendException
import org.cryptomator.domain.usecases.cloud.Flag
import org.cryptomator.domain.usecases.vault.UnlockToken
import org.cryptomator.util.crypto.HubDeviceCryptor
import java.security.SecureRandom
import java.text.ParseException

class HubkeyCryptoCloudProvider(
private val cryptoCloudContentRepositoryFactory: CryptoCloudContentRepositoryFactory, //
private val secureRandom: SecureRandom
) : CryptoCloudProvider {

@Throws(BackendException::class)
override fun create(location: CloudFolder, password: CharSequence) {
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 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 UnsupportedOperationException("Hub vaults do not support password based unlock")
}

override fun unlock(vault: Vault, unverifiedVaultConfig: UnverifiedVaultConfig, vaultKeyJwe: String, userKeyJwe: String, cancelledFlag: Flag): Vault {
val vaultKey: JWEObject
val userKey: JWEObject
try {
vaultKey = JWEObject.parse(vaultKeyJwe)
userKey = JWEObject.parse(userKeyJwe)
} catch (e: ParseException) {
throw FatalBackendException("Failed to parse JWE strings", e)
}
val masterkey = HubDeviceCryptor.getInstance().decryptVaultKey(vaultKey, userKey)
val vaultConfig = verify(masterkey.encoded, unverifiedVaultConfig)
val vaultFormat = vaultConfig.vaultFormat
assertVaultVersionIsSupported(vaultConfig.vaultFormat)
val shorteningThreshold = vaultConfig.shorteningThreshold
val cryptor = cryptorFor(masterkey, vaultConfig.cipherCombo)
if (cancelledFlag.get()) {
throw CancellationException()
}
val unlockedVault = Vault.aCopyOf(vault) //
.withUnlocked(true) //
.withFormat(vaultFormat) //
.withShorteningThreshold(shorteningThreshold) //
.build()
cryptoCloudContentRepositoryFactory.registerCryptor(unlockedVault, cryptor)
return unlockedVault
}

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

// Visible for testing
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 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_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 UnsupportedOperationException("Hub vaults do not support password based unlock")
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ class MasterkeyCryptoCloudProvider(
}
}

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

@Throws(BackendException::class)
override fun createUnlockToken(vault: Vault, unverifiedVaultConfig: Optional<UnverifiedVaultConfig>): UnlockTokenImpl {
val vaultLocation = vaultLocation(vault)
Expand Down Expand Up @@ -153,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 @@ -286,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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ 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
import org.cryptomator.domain.exception.vaultconfig.VaultKeyInvalidException
Expand Down Expand Up @@ -82,8 +84,45 @@ class VaultConfig private constructor(builder: VaultConfigBuilder) {
fun decode(token: String): UnverifiedVaultConfig {
val unverifiedJwt = JWT.decode(token)
val vaultFormat = unverifiedJwt.getClaim(JSON_KEY_VAULTFORMAT).asInt()
val keyId = URI.create(unverifiedJwt.keyId)
return UnverifiedVaultConfig(token, keyId, vaultFormat)
val keyId = try {
URI.create(unverifiedJwt.keyId)
} catch (e: IllegalArgumentException) {
throw VaultConfigLoadException("Invalid 'keyId' in JWT: ${e.message}", e)
}
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 = getApiBaseUrl(hubClaim)
return UnverifiedHubVaultConfig(token, keyId, vaultFormat, clientId, authEndpoint, tokenEndpoint, apiBaseUrl)
}
}
}

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(adjustedUriString)
} catch (e: IllegalArgumentException) {
throw VaultConfigLoadException("Invalid '$fieldName' URI: ${e.message}", e)
}
}

private fun getApiBaseUrl(hubClaim: Map<String, Any>): URI {
val apiBaseUrlKey = "apiBaseUrl"
return if (hubClaim.containsKey(apiBaseUrlKey)) {
parseUri(hubClaim, apiBaseUrlKey, ensureTrailingSlash = true)
} else {
parseUri(hubClaim, "devicesResourceUrl", ensureTrailingSlash = true).resolve("..")
}
}

@JvmStatic
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ public Cloud unlock(UnlockToken token, Optional<UnverifiedVaultConfig> unverifie
return decryptedViewOf(vaultWithVersion);
}


@Override
public Cloud unlock(Vault vault, UnverifiedVaultConfig unverifiedVaultConfig, String vaultKeyJwe, String userKeyJwe, Flag cancelledFlag) throws BackendException {
Vault vaultWithVersion = cryptoCloudFactory.unlock(vault, unverifiedVaultConfig, vaultKeyJwe, userKeyJwe, cancelledFlag);
return decryptedViewOf(vaultWithVersion);
}

@Override
public UnlockToken prepareUnlock(Vault vault, Optional<UnverifiedVaultConfig> unverifiedVaultConfig) throws BackendException {
return cryptoCloudFactory.createUnlockToken(vault, unverifiedVaultConfig);
Expand Down
Loading

0 comments on commit a889842

Please sign in to comment.