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 all 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
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());
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 @@ -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"
SailReal marked this conversation as resolved.
Show resolved Hide resolved
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
}
JaniruTEC marked this conversation as resolved.
Show resolved Hide resolved

@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)
SailReal marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
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)
}
}
SailReal marked this conversation as resolved.
Show resolved Hide resolved
}

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("..")
Copy link
Contributor

@JaniruTEC JaniruTEC Dec 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For future reference: ensureTrailingSlash = true is vital here.
If the devicesResourceUrl doesn't end with a slash, the returned URI will be https://example.org/hub/ instead of https://example.org/hub/api/.

See: RFC2396 5.2 (6) a)

}
}

@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
Loading