diff --git a/hub/src/main/java/cloud/katta/core/DefaultDeviceSetupCallback.java b/hub/src/main/java/cloud/katta/core/DefaultDeviceSetupCallback.java new file mode 100644 index 00000000..5db04af9 --- /dev/null +++ b/hub/src/main/java/cloud/katta/core/DefaultDeviceSetupCallback.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.core; + +import ch.cyberduck.core.Credentials; +import ch.cyberduck.core.Host; +import ch.cyberduck.core.LocaleFactory; +import ch.cyberduck.core.LoginCallback; +import ch.cyberduck.core.LoginOptions; +import ch.cyberduck.core.StringAppender; +import ch.cyberduck.core.exception.LoginCanceledException; + +import cloud.katta.model.AccountKeyAndDeviceName; +import cloud.katta.workflows.exceptions.AccessException; + +public class DefaultDeviceSetupCallback implements DeviceSetupCallback { + + private final LoginCallback prompt; + + public DefaultDeviceSetupCallback(final LoginCallback prompt) { + this.prompt = prompt; + } + + @Override + public AccountKeyAndDeviceName displayAccountKeyAndAskDeviceName(final Host bookmark, final AccountKeyAndDeviceName accountKeyAndDeviceName) throws AccessException { + try { + final Credentials input = prompt.prompt(bookmark, accountKeyAndDeviceName.accountKey(), + LocaleFactory.localizedString("Account Key", "Hub"), + new StringAppender() + .append(LocaleFactory.localizedString("On first login, every user gets a unique Account Key", "Hub")) + .append(LocaleFactory.localizedString("Your Account Key is required to login from other apps or browsers", "Hub")) + .append(LocaleFactory.localizedString("You can see a list of authorized apps on your profile page", "Hub")).toString(), + new LoginOptions() + .usernamePlaceholder(LocaleFactory.localizedString("Account Key", "Hub")) + // Account key not editable + .user(false) + .passwordPlaceholder(accountKeyAndDeviceName.deviceName()) + // Input device name + .password(true) + .keychain(false) + ); + return new AccountKeyAndDeviceName() + .withDeviceName(input.getUsername()) + .withAccountKey(input.getPassword()); + } + catch(LoginCanceledException e) { + throw new AccessException(e); + } + } + + @Override + public AccountKeyAndDeviceName askForAccountKeyAndDeviceName(final Host bookmark, final String initialDeviceName) throws AccessException { + try { + final Credentials input = prompt.prompt(bookmark, initialDeviceName, + LocaleFactory.localizedString("Authorization Required", "Hub"), + new StringAppender() + .append(LocaleFactory.localizedString("This is your first login on this device.", "Hub")) + .append(LocaleFactory.localizedString("Your Account Key is required to link this browser to your account.", "Hub")).toString(), + new LoginOptions() + .usernamePlaceholder(LocaleFactory.localizedString("Device Name", "Hub")) + // Customize device name + .user(true) + .passwordPlaceholder(LocaleFactory.localizedString("Account Key", "Hub")) + // Input account key + .password(true) + .keychain(false) + ); + return new AccountKeyAndDeviceName() + .withDeviceName(input.getUsername()) + .withAccountKey(input.getPassword()); + } + catch(LoginCanceledException e) { + throw new AccessException(e); + } + } +} diff --git a/hub/src/main/java/cloud/katta/core/DeviceSetupCallback.java b/hub/src/main/java/cloud/katta/core/DeviceSetupCallback.java index 229e691b..2d5327ca 100644 --- a/hub/src/main/java/cloud/katta/core/DeviceSetupCallback.java +++ b/hub/src/main/java/cloud/katta/core/DeviceSetupCallback.java @@ -17,10 +17,10 @@ public interface DeviceSetupCallback { /** * Prompt user for device name * - * @return Device name + * @return Account key and device name * @throws AccessException Canceled prompt by user */ - String displayAccountKeyAndAskDeviceName(Host bookmark, AccountKeyAndDeviceName accountKeyAndDeviceName) throws AccessException; + AccountKeyAndDeviceName displayAccountKeyAndAskDeviceName(Host bookmark, AccountKeyAndDeviceName accountKeyAndDeviceName) throws AccessException; /** * Prompt user for existing account key @@ -50,7 +50,7 @@ default UserKeys generateUserKeys() { DeviceSetupCallback disabled = new DeviceSetupCallback() { @Override - public String displayAccountKeyAndAskDeviceName(final Host bookmark, final AccountKeyAndDeviceName accountKeyAndDeviceName) throws AccessException { + public AccountKeyAndDeviceName displayAccountKeyAndAskDeviceName(final Host bookmark, final AccountKeyAndDeviceName accountKeyAndDeviceName) throws AccessException { throw new AccessException("Disabled"); } diff --git a/hub/src/main/java/cloud/katta/core/DeviceSetupCallbackFactory.java b/hub/src/main/java/cloud/katta/core/DeviceSetupCallbackFactory.java deleted file mode 100644 index b1bc99e6..00000000 --- a/hub/src/main/java/cloud/katta/core/DeviceSetupCallbackFactory.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2025 shift7 GmbH. All rights reserved. - */ - -package cloud.katta.core; - -import ch.cyberduck.core.Factory; - -import org.apache.commons.lang3.reflect.ConstructorUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; - -public final class DeviceSetupCallbackFactory extends Factory { - private static final Logger log = LogManager.getLogger(DeviceSetupCallbackFactory.class); - - private DeviceSetupCallbackFactory() { - super("factory.devicesetupcallback.class"); - } - - public DeviceSetupCallback create() { - try { - final Constructor constructor - = ConstructorUtils.getMatchingAccessibleConstructor(clazz); - if(null == constructor) { - log.warn("No default controller in {}", constructor.getClass()); - // Call default constructor for disabled implementations - return clazz.getDeclaredConstructor().newInstance(); - } - return constructor.newInstance(); - } - catch(InstantiationException | InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { - log.error("Failure loading callback class {}. {}", clazz, e.getMessage()); - return DeviceSetupCallback.disabled; - } - } - - private static DeviceSetupCallbackFactory singleton; - - /** - * @return Firs tLogin Device Setup Callback instance for the current platform. - */ - public static synchronized DeviceSetupCallback get() { - if(null == singleton) { - singleton = new DeviceSetupCallbackFactory(); - } - return singleton.create(); - } -} diff --git a/hub/src/main/java/cloud/katta/crypto/uvf/UvfMetadataPayloadPasswordCallback.java b/hub/src/main/java/cloud/katta/crypto/uvf/UvfMetadataPayloadPasswordCallback.java index 1929f2a9..8d364e71 100644 --- a/hub/src/main/java/cloud/katta/crypto/uvf/UvfMetadataPayloadPasswordCallback.java +++ b/hub/src/main/java/cloud/katta/crypto/uvf/UvfMetadataPayloadPasswordCallback.java @@ -15,19 +15,18 @@ public class UvfMetadataPayloadPasswordCallback extends DisabledPasswordCallback { - private final UvfMetadataPayload payload; + private final String payloadJson; - public UvfMetadataPayloadPasswordCallback(final UvfMetadataPayload payload) { - this.payload = payload; + public UvfMetadataPayloadPasswordCallback(final UvfMetadataPayload payload) throws JsonProcessingException { + this(payload.toJSON()); + } + + public UvfMetadataPayloadPasswordCallback(final String payloadJson) { + this.payloadJson = payloadJson; } @Override public Credentials prompt(final Host bookmark, final String title, final String reason, final LoginOptions options) throws LoginCanceledException { - try { - return new VaultCredentials(payload.toJSON()); - } - catch(JsonProcessingException e) { - throw new LoginCanceledException(e); - } + return new VaultCredentials(payloadJson); } } diff --git a/hub/src/main/java/cloud/katta/protocols/hub/HubAwareProfile.java b/hub/src/main/java/cloud/katta/protocols/hub/HubAwareProfile.java new file mode 100644 index 00000000..a15bc50d --- /dev/null +++ b/hub/src/main/java/cloud/katta/protocols/hub/HubAwareProfile.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.protocols.hub; + +import ch.cyberduck.core.Profile; +import ch.cyberduck.core.Protocol; + +import cloud.katta.client.model.ConfigDto; +import cloud.katta.model.StorageProfileDtoWrapper; +import cloud.katta.protocols.hub.serializer.HubConfigDtoDeserializer; +import cloud.katta.protocols.hub.serializer.StorageProfileDtoWrapperDeserializer; + +public final class HubAwareProfile extends Profile { + + public HubAwareProfile(final Protocol parent, final ConfigDto configDto, final StorageProfileDtoWrapper storageProfile) { + super(parent, new HubConfigDtoDeserializer(configDto, new StorageProfileDtoWrapperDeserializer(storageProfile))); + } +} diff --git a/hub/src/main/java/cloud/katta/protocols/hub/HubGrantAccessSchedulerService.java b/hub/src/main/java/cloud/katta/protocols/hub/HubGrantAccessSchedulerService.java index 3749e5e9..3bc04dbc 100644 --- a/hub/src/main/java/cloud/katta/protocols/hub/HubGrantAccessSchedulerService.java +++ b/hub/src/main/java/cloud/katta/protocols/hub/HubGrantAccessSchedulerService.java @@ -8,7 +8,7 @@ import ch.cyberduck.core.HostPasswordStore; import ch.cyberduck.core.PasswordCallback; import ch.cyberduck.core.exception.BackgroundException; -import ch.cyberduck.core.preferences.HostPreferences; +import ch.cyberduck.core.preferences.HostPreferencesFactory; import ch.cyberduck.core.shared.ThreadPoolSchedulerFeature; import org.apache.logging.log4j.LogManager; @@ -18,6 +18,7 @@ import cloud.katta.client.ApiException; import cloud.katta.client.api.DeviceResourceApi; +import cloud.katta.client.api.StorageProfileResourceApi; import cloud.katta.client.api.UsersResourceApi; import cloud.katta.client.api.VaultResourceApi; import cloud.katta.client.model.Role; @@ -25,7 +26,6 @@ import cloud.katta.crypto.UserKeys; import cloud.katta.protocols.hub.exceptions.HubExceptionMappingService; import cloud.katta.workflows.DeviceKeysServiceImpl; -import cloud.katta.workflows.GrantAccessService; import cloud.katta.workflows.GrantAccessServiceImpl; import cloud.katta.workflows.UserKeysServiceImpl; import cloud.katta.workflows.exceptions.AccessException; @@ -36,40 +36,32 @@ public class HubGrantAccessSchedulerService extends ThreadPoolSchedulerFeature accessibleVaults = vaults.apiVaultsAccessibleGet(Role.OWNER); - + final List accessibleVaults = new VaultResourceApi(session.getClient()).apiVaultsAccessibleGet(Role.OWNER); for(final VaultDto accessibleVault : accessibleVaults) { if(Boolean.TRUE.equals(accessibleVault.getArchived())) { log.debug("Skip archived vault {}", accessibleVault); continue; } - service.grantAccessToUsersRequiringAccessGrant(accessibleVault.getId(), userKeys); + new GrantAccessServiceImpl( + new VaultResourceApi(session.getClient()), + new StorageProfileResourceApi(session.getClient()), + new UsersResourceApi(session.getClient()) + ).grantAccessToUsersRequiringAccessGrant(accessibleVault.getId(), userKeys); } userKeys.destroy(); } diff --git a/hub/src/main/java/cloud/katta/protocols/hub/HubOAuthTokensCredentialsConfigurator.java b/hub/src/main/java/cloud/katta/protocols/hub/HubOAuthTokensCredentialsConfigurator.java new file mode 100644 index 00000000..894345fe --- /dev/null +++ b/hub/src/main/java/cloud/katta/protocols/hub/HubOAuthTokensCredentialsConfigurator.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.protocols.hub; + +import ch.cyberduck.core.Credentials; +import ch.cyberduck.core.CredentialsConfigurator; +import ch.cyberduck.core.Host; +import ch.cyberduck.core.HostPasswordStore; +import ch.cyberduck.core.OAuthTokens; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public class HubOAuthTokensCredentialsConfigurator implements CredentialsConfigurator { + private static final Logger log = LogManager.getLogger(HubOAuthTokensCredentialsConfigurator.class); + + private final HostPasswordStore keychain; + private final Host host; + + private OAuthTokens tokens = OAuthTokens.EMPTY; + + public HubOAuthTokensCredentialsConfigurator(final HostPasswordStore keychain, final Host host) { + this.keychain = keychain; + this.host = host; + } + + @Override + public Credentials configure(final Host host) { + return new Credentials(host.getCredentials()).setOauth(tokens); + } + + @Override + public CredentialsConfigurator reload() { + if(tokens.isExpired()) { + log.debug("Reload expired tokens from keychain for {}", host); + tokens = keychain.findOAuthTokens(host); + log.debug("Retrieved tokens {}", tokens); + } + return this; + } +} diff --git a/hub/src/main/java/cloud/katta/protocols/hub/HubProtocol.java b/hub/src/main/java/cloud/katta/protocols/hub/HubProtocol.java index bb1243d2..3710a791 100644 --- a/hub/src/main/java/cloud/katta/protocols/hub/HubProtocol.java +++ b/hub/src/main/java/cloud/katta/protocols/hub/HubProtocol.java @@ -62,4 +62,9 @@ public boolean isUsernameConfigurable() { public boolean isPasswordConfigurable() { return false; } + + @Override + public VersioningMode getVersioningMode() { + return VersioningMode.storage; + } } diff --git a/hub/src/main/java/cloud/katta/protocols/hub/HubSession.java b/hub/src/main/java/cloud/katta/protocols/hub/HubSession.java index 70403e18..2f8b0615 100644 --- a/hub/src/main/java/cloud/katta/protocols/hub/HubSession.java +++ b/hub/src/main/java/cloud/katta/protocols/hub/HubSession.java @@ -4,40 +4,49 @@ package cloud.katta.protocols.hub; -import ch.cyberduck.core.Credentials; -import ch.cyberduck.core.DisabledListProgressListener; -import ch.cyberduck.core.Host; -import ch.cyberduck.core.HostKeyCallback; -import ch.cyberduck.core.HostPasswordStore; -import ch.cyberduck.core.ListService; -import ch.cyberduck.core.LocaleFactory; -import ch.cyberduck.core.LoginCallback; -import ch.cyberduck.core.OAuthTokens; -import ch.cyberduck.core.PasswordStoreFactory; -import ch.cyberduck.core.Profile; -import ch.cyberduck.core.ProtocolFactory; -import ch.cyberduck.core.Session; +import ch.cyberduck.core.*; import ch.cyberduck.core.exception.BackgroundException; import ch.cyberduck.core.exception.ConnectionCanceledException; import ch.cyberduck.core.exception.InteroperabilityException; import ch.cyberduck.core.exception.LoginCanceledException; +import ch.cyberduck.core.exception.UnsupportedException; +import ch.cyberduck.core.features.AttributesFinder; +import ch.cyberduck.core.features.Copy; +import ch.cyberduck.core.features.Delete; +import ch.cyberduck.core.features.Directory; +import ch.cyberduck.core.features.Find; import ch.cyberduck.core.features.Home; +import ch.cyberduck.core.features.Location; +import ch.cyberduck.core.features.Move; +import ch.cyberduck.core.features.Read; import ch.cyberduck.core.features.Scheduler; +import ch.cyberduck.core.features.Timestamp; +import ch.cyberduck.core.features.Touch; +import ch.cyberduck.core.features.Write; +import ch.cyberduck.core.http.CustomServiceUnavailableRetryStrategy; +import ch.cyberduck.core.http.ExecutionCountServiceUnavailableRetryStrategy; import ch.cyberduck.core.http.HttpSession; +import ch.cyberduck.core.io.StatusOutputStream; +import ch.cyberduck.core.io.StreamListener; import ch.cyberduck.core.oauth.OAuth2AuthorizationService; import ch.cyberduck.core.oauth.OAuth2ErrorResponseInterceptor; import ch.cyberduck.core.oauth.OAuth2RequestInterceptor; -import ch.cyberduck.core.preferences.PreferencesFactory; +import ch.cyberduck.core.preferences.HostPreferencesFactory; import ch.cyberduck.core.proxy.ProxyFinder; import ch.cyberduck.core.ssl.X509KeyManager; import ch.cyberduck.core.ssl.X509TrustManager; +import ch.cyberduck.core.synchronization.ComparisonService; import ch.cyberduck.core.threading.CancelCallback; -import ch.cyberduck.core.vault.VaultRegistry; +import ch.cyberduck.core.transfer.TransferStatus; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.io.InputStream; +import java.util.Map; +import java.util.Optional; + import cloud.katta.client.ApiException; import cloud.katta.client.HubApiClient; import cloud.katta.client.api.ConfigResourceApi; @@ -45,7 +54,7 @@ import cloud.katta.client.model.ConfigDto; import cloud.katta.client.model.UserDto; import cloud.katta.core.DeviceSetupCallback; -import cloud.katta.core.DeviceSetupCallbackFactory; +import cloud.katta.crypto.DeviceKeys; import cloud.katta.crypto.UserKeys; import cloud.katta.protocols.hub.exceptions.HubExceptionMappingService; import cloud.katta.protocols.hub.serializer.HubConfigDtoDeserializer; @@ -63,59 +72,58 @@ public class HubSession extends HttpSession { private static final Logger log = LogManager.getLogger(HubSession.class); private final HostPasswordStore keychain = PasswordStoreFactory.get(); - private final ProtocolFactory protocols = ProtocolFactory.get(); /** * Periodically grant vault access to users */ private final Scheduler access = new HubGrantAccessSchedulerService(this, keychain); - private final HubVaultRegistry registry = new HubVaultRegistry(); - - private HubVaultListService vaults; - /** * Interceptor for OpenID connect flow */ private OAuth2RequestInterceptor authorizationService; - private UserDto me; + + private ConfigDto config; + + private final ExpiringObjectHolder userDtoHolder + = new ExpiringObjectHolder<>(-1L == preferences.getLong("katta.user.ttl") ? 60000 : preferences.getLong("katta.user.ttl")); + + private final ExpiringObjectHolder userKeysHolder + = new ExpiringObjectHolder<>(-1L == preferences.getLong("katta.userkeys.ttl") ? 60000 : preferences.getLong("katta.userkeys.ttl")); + + private HubVaultListService vaults; public HubSession(final Host host, final X509TrustManager trust, final X509KeyManager key) { super(host, trust, key); } - @Override - public Session withRegistry(final VaultRegistry ignored) { - return super.withRegistry(registry); - } - - public HubVaultRegistry getRegistry() { - return registry; + public static HubSession coerce(final Session session) { + return (HubSession) session; } @Override protected HubApiClient connect(final ProxyFinder proxy, final HostKeyCallback key, final LoginCallback prompt, final CancelCallback cancel) throws BackgroundException { final HttpClientBuilder configuration = builder.build(proxy, this, prompt); - if(host.getProtocol().isBundled()) { + final Protocol bundled = host.getProtocol(); + if(bundled.isBundled()) { // Use REST API for bootstrapping via /api/config final HubApiClient client = new HubApiClient(host, configuration.build()); try { // Obtain OAuth configuration - final ConfigDto configDto = new ConfigResourceApi(client).apiConfigGet(); - - int minHubApiLevel = PreferencesFactory.get().getInteger("cloud.katta.min_api_level"); - final Integer apiLevel = configDto.getApiLevel(); + config = new ConfigResourceApi(client).apiConfigGet(); + final int minHubApiLevel = HostPreferencesFactory.get(host).getInteger("cloud.katta.min_api_level"); + final Integer apiLevel = config.getApiLevel(); if(apiLevel == null || apiLevel < minHubApiLevel) { final String detail = String.format("Client requires API level at least %s, found %s, for hub %s", minHubApiLevel, apiLevel, host); log.error(detail); throw new InteroperabilityException(LocaleFactory.localizedString("Login failed", "Credentials"), detail); } - final String hubId = configDto.getUuid(); + final String hubId = config.getUuid(); log.debug("Configure bookmark with id {}", hubId); host.setUuid(hubId); - final Profile profile = new Profile(host.getProtocol(), new HubConfigDtoDeserializer(configDto)); + final Profile profile = new Profile(bundled, new HubConfigDtoDeserializer(config)); log.debug("Apply profile {} to bookmark {}", profile, host); host.setProtocol(profile); } @@ -136,14 +144,14 @@ protected HubApiClient connect(final ProxyFinder proxy, final HostKeyCallback ke host.getProtocol().isOAuthPKCE(), prompt) .withFlowType(OAuth2AuthorizationService.FlowType.valueOf(host.getProtocol().getAuthorization())) .withRedirectUri(host.getProtocol().getOAuthRedirectUrl()); - configuration.setServiceUnavailableRetryStrategy(new OAuth2ErrorResponseInterceptor(host, authorizationService)); + configuration.setServiceUnavailableRetryStrategy(new CustomServiceUnavailableRetryStrategy(host, + new ExecutionCountServiceUnavailableRetryStrategy(new OAuth2ErrorResponseInterceptor(host, authorizationService)))); configuration.addInterceptorLast(authorizationService); return new HubApiClient(host, configuration.build()); } @Override public void login(final LoginCallback prompt, final CancelCallback cancel) throws BackgroundException { - final DeviceSetupCallback setup = DeviceSetupCallbackFactory.get(); final Credentials credentials = host.getCredentials(); credentials.setOauth(authorizationService.validate(credentials.getOauth())); try { @@ -154,27 +162,35 @@ public void login(final LoginCallback prompt, final CancelCallback cancel) throw log.warn("Failure {} decoding JWT {}", e, credentials.getOauth().getIdToken()); throw new LoginCanceledException(e); } + final UserDto me = this.getMe(); + log.debug("Retrieved user {}", me); + // Ensure device key is available + final DeviceSetupCallback setup = prompt.getFeature(DeviceSetupCallback.class); + log.debug("Configured with setup prompt {}", setup); + final UserKeys userKeys = this.getUserKeys(setup); + log.debug("Retrieved user keys {}", userKeys); + vaults = new HubVaultListService(this, prompt); + } + + private UserKeys pair(final DeviceSetupCallback setup) throws BackgroundException { try { - me = new UsersResourceApi(client).apiUsersMeGet(true, false); - log.debug("Retrieved user {}", me); - final UserKeys userKeys = new UserKeysServiceImpl(this).getOrCreateUserKeys(host, me, - new DeviceKeysServiceImpl(keychain).getOrCreateDeviceKeys(host, setup), setup); + final DeviceKeys deviceKeys = new DeviceKeysServiceImpl(keychain).getOrCreateDeviceKeys(host, setup); + log.debug("Retrieved device keys {}", deviceKeys); + final UserKeys userKeys = new UserKeysServiceImpl(this).getOrCreateUserKeys(host, + this.getMe(), deviceKeys, setup); log.debug("Retrieved user keys {}", userKeys); - // Ensure vaults are registered - final OAuthTokens tokens = new OAuthTokens(credentials.getOauth().getAccessToken(), credentials.getOauth().getRefreshToken(), credentials.getOauth().getExpiryInMilliseconds(), - credentials.getOauth().getIdToken()); - vaults = new HubVaultListService(protocols, this, trust, key, registry, tokens); - vaults.list(Home.root(), new DisabledListProgressListener()); + return userKeys; } catch(SecurityFailure e) { - throw new InteroperabilityException(LocaleFactory.localizedString("Login failed", "Credentials"), e); - } - catch(ApiException e) { - throw new HubExceptionMappingService().map(e); + // Repeat until canceled by user + return this.pair(setup); } catch(AccessException e) { throw new ConnectionCanceledException(e); } + catch(ApiException e) { + throw new HubExceptionMappingService().map(e); + } } @Override @@ -183,8 +199,35 @@ protected void logout() { client.getHttpClient().close(); } - public UserDto getMe() { - return me; + /** + * + * @return Null prior login + */ + public UserDto getMe() throws BackgroundException { + try { + if(userDtoHolder.get() == null) { + userDtoHolder.set(new UsersResourceApi(client).apiUsersMeGet(true, false)); + } + return userDtoHolder.get(); + } + catch(ApiException e) { + throw new HubExceptionMappingService().map(e); + } + } + + public ConfigDto getConfig() { + return config; + } + + /** + * + * @return Destroyed keys after login + */ + public UserKeys getUserKeys(final DeviceSetupCallback setup) throws BackgroundException { + if(userKeysHolder.get() == null) { + userKeysHolder.set(this.pair(setup)); + } + return userKeysHolder.get(); } @Override @@ -196,6 +239,130 @@ public T _getFeature(final Class type) { if(type == Scheduler.class) { return (T) access; } - return host.getProtocol().getFeature(type); + if(type == Home.class) { + return (T) (Home) Home::root; + } + if(type == AttributesFinder.class) { + return (T) (AttributesFinder) (f, l) -> f.attributes(); + } + if(type == Location.class) { + return (T) new HubStorageLocationService(this); + } + if(type == Find.class) { + return (T) (Find) (file, listener) -> new SimplePathPredicate(registry.find(HubSession.this, file).getHome()).test(file); + } + if(type == Read.class) { + return (T) new Read() { + @Override + public InputStream read(final Path file, final TransferStatus status, final ConnectionCallback callback) throws BackgroundException { + log.warn("Deny read access to {}", file); + throw new UnsupportedException().withFile(file); + } + + @Override + public void preflight(final Path file) throws BackgroundException { + throw new UnsupportedException().withFile(file); + } + }; + } + if(type == Write.class) { + return (T) new Write() { + @Override + public StatusOutputStream write(final Path file, final TransferStatus status, final ConnectionCallback callback) throws BackgroundException { + log.warn("Deny write access to {}", file); + throw new UnsupportedException().withFile(file); + } + + @Override + public void preflight(final Path file) throws BackgroundException { + throw new UnsupportedException().withFile(file); + } + }; + } + if(type == Touch.class) { + return (T) new Touch() { + @Override + public Path touch(final Write writer, final Path file, final TransferStatus status) throws BackgroundException { + log.warn("Deny write access to {}", file); + throw new UnsupportedException().withFile(file); + } + + @Override + public void preflight(final Path workdir, final String filename) throws BackgroundException { + throw new UnsupportedException().withFile(workdir); + } + }; + } + if(type == Directory.class) { + return (T) new Directory() { + @Override + public Path mkdir(final Write writer, final Path folder, final TransferStatus status) throws BackgroundException { + log.warn("Deny write access to {}", folder); + throw new UnsupportedException().withFile(folder); + } + + @Override + public void preflight(final Path workdir, final String filename) throws BackgroundException { + throw new UnsupportedException().withFile(workdir); + } + }; + } + if(type == Move.class) { + return (T) new Move() { + @Override + public Path move(final Path source, final Path target, final TransferStatus status, final Delete.Callback delete, final ConnectionCallback prompt) throws BackgroundException { + log.warn("Deny write access to {}", source); + throw new UnsupportedException().withFile(source); + } + + @Override + public void preflight(final Path source, final Optional target) throws BackgroundException { + throw new UnsupportedException().withFile(source); + } + }; + } + if(type == Copy.class) { + return (T) new Copy() { + @Override + public Path copy(final Path source, final Path target, final TransferStatus status, final ConnectionCallback prompt, final StreamListener listener) throws BackgroundException { + log.warn("Deny write access to {}", source); + throw new UnsupportedException().withFile(source); + } + + @Override + public void preflight(final Path source, final Optional target) throws BackgroundException { + throw new UnsupportedException().withFile(source); + } + }; + } + if(type == Delete.class) { + return (T) new Delete() { + @Override + public void delete(final Map files, final PasswordCallback prompt, final Callback callback) throws BackgroundException { + log.warn("Deny write access to {}", files); + throw new UnsupportedException(); + } + + @Override + public void preflight(final Path file) throws BackgroundException { + throw new UnsupportedException().withFile(file); + } + }; + } + if(type == Timestamp.class) { + return (T) (Timestamp) (file, status) -> { + throw new UnsupportedException().withFile(file); + }; + } + if(type == ComparisonService.class) { + return (T) new HubVaultStorageAwareComparisonService(this); + } + if(type == CredentialsConfigurator.class) { + return (T) new HubOAuthTokensCredentialsConfigurator(keychain, host); + } + if(type == OAuth2RequestInterceptor.class) { + return (T) authorizationService; + } + return super._getFeature(type); } } diff --git a/hub/src/main/java/cloud/katta/protocols/hub/HubStorageLocationService.java b/hub/src/main/java/cloud/katta/protocols/hub/HubStorageLocationService.java new file mode 100644 index 00000000..f91caa17 --- /dev/null +++ b/hub/src/main/java/cloud/katta/protocols/hub/HubStorageLocationService.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.protocols.hub; + +import ch.cyberduck.core.Path; +import ch.cyberduck.core.features.Location; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import cloud.katta.client.ApiException; +import cloud.katta.client.api.StorageProfileResourceApi; +import cloud.katta.client.model.StorageProfileDto; +import cloud.katta.crypto.uvf.UvfMetadataPayload; +import cloud.katta.crypto.uvf.VaultMetadataJWEAutomaticAccessGrantDto; +import cloud.katta.crypto.uvf.VaultMetadataJWEBackendDto; +import cloud.katta.model.StorageProfileDtoWrapper; + +public class HubStorageLocationService implements Location { + private static final Logger log = LogManager.getLogger(HubStorageLocationService.class); + + private final HubSession session; + + public HubStorageLocationService(final HubSession session) { + this.session = session; + } + + @Override + public Name getDefault(final Path file) { + return Location.unknown; + } + + @Override + public Set getLocations(final Path file) { + try { + final Set regions = new HashSet<>(); + final List storageProfileDtos = new StorageProfileResourceApi(session.getClient()) + .apiStorageprofileGet(false); + for(StorageProfileDto storageProfileDto : storageProfileDtos) { + final StorageProfileDtoWrapper storageProfile = StorageProfileDtoWrapper.coerce(storageProfileDto); + for(String region : storageProfile.getRegions()) { + regions.add(new StorageLocation(storageProfile.getId().toString(), region, storageProfile.getName())); + } + } + return regions; + } + catch(ApiException e) { + log.warn("Failed to retrieve storage locations from server", e); + return Collections.emptySet(); + } + } + + @Override + public Name getLocation(final Path file) { + return StorageLocation.fromIdentifier(file.attributes().getRegion()); + } + + public static final class StorageLocation extends Name { + private final String storageProfileId; + /** + * AWS location + */ + private final String region; + private final String storageProfileName; + + /** + * + * @param storageProfileId UUID of storage profile configuration + * @param region AWS location + * @param storageProfileName Description + */ + public StorageLocation(final String storageProfileId, final String region, final String storageProfileName) { + super(String.format("%s,%s", storageProfileId, null == region ? StringUtils.EMPTY : region)); + this.storageProfileId = storageProfileId; + this.region = region; + this.storageProfileName = storageProfileName; + } + + /** + * + * @return Storage Profile Id + */ + public String getProfile() { + return storageProfileId; + } + + public String getRegion() { + return region; + } + + @Override + public String toString() { + return String.format("%s (%s)", storageProfileName, region); + } + + /** + * Parse a storage location from an identifier containing storage profile and AWS location. + * + * @param identifier Storage profile identifier and AWS region separated by dash + * @return Location with storage profile as identifier and AWS location as region + */ + public static StorageLocation fromIdentifier(final String identifier) { + final String[] parts = identifier.split(","); + if(parts.length != 2) { + return new StorageLocation(identifier, null, null); + } + return new StorageLocation(StringUtils.isBlank(parts[0]) ? null : parts[0], StringUtils.isBlank(parts[1]) ? null : parts[1], null); + } + + public UvfMetadataPayload toUvfMetadataPayload(final Path bucket) { + return UvfMetadataPayload.create() + .withStorage(new VaultMetadataJWEBackendDto() + .provider(this.getProfile()) + .defaultPath(bucket.getAbsolute()) + .region(this.getRegion()) + .nickname(null != bucket.attributes().getDisplayname() ? bucket.attributes().getDisplayname() : "Vault")) + .withAutomaticAccessGrant(new VaultMetadataJWEAutomaticAccessGrantDto() + .enabled(true) + .maxWotDepth(null)); + } + } +} diff --git a/hub/src/main/java/cloud/katta/protocols/hub/HubUVFVault.java b/hub/src/main/java/cloud/katta/protocols/hub/HubUVFVault.java index 8c9375f7..c011f42d 100644 --- a/hub/src/main/java/cloud/katta/protocols/hub/HubUVFVault.java +++ b/hub/src/main/java/cloud/katta/protocols/hub/HubUVFVault.java @@ -4,30 +4,52 @@ package cloud.katta.protocols.hub; -import ch.cyberduck.core.AbstractPath; import ch.cyberduck.core.DisabledCancelCallback; import ch.cyberduck.core.DisabledHostKeyCallback; -import ch.cyberduck.core.DisabledListProgressListener; -import ch.cyberduck.core.DisabledLoginCallback; -import ch.cyberduck.core.ListService; +import ch.cyberduck.core.Host; +import ch.cyberduck.core.HostUrlProvider; +import ch.cyberduck.core.LoginCallback; import ch.cyberduck.core.PasswordCallback; import ch.cyberduck.core.Path; +import ch.cyberduck.core.PathAttributes; import ch.cyberduck.core.Session; import ch.cyberduck.core.cryptomator.ContentWriter; import ch.cyberduck.core.cryptomator.UVFVault; import ch.cyberduck.core.exception.BackgroundException; +import ch.cyberduck.core.exception.InteroperabilityException; import ch.cyberduck.core.exception.UnsupportedException; +import ch.cyberduck.core.features.AttributesFinder; import ch.cyberduck.core.features.Directory; import ch.cyberduck.core.features.Write; +import ch.cyberduck.core.preferences.HostPreferencesFactory; import ch.cyberduck.core.preferences.PreferencesFactory; import ch.cyberduck.core.proxy.ProxyFactory; +import ch.cyberduck.core.s3.S3Session; import ch.cyberduck.core.transfer.TransferStatus; +import ch.cyberduck.core.vault.VaultCredentials; +import ch.cyberduck.core.vault.VaultUnlockCancelException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.joda.time.DateTime; import java.nio.charset.StandardCharsets; +import java.util.Collections; import java.util.EnumSet; +import java.util.UUID; + +import cloud.katta.client.ApiException; +import cloud.katta.client.api.VaultResourceApi; +import cloud.katta.client.model.UserDto; +import cloud.katta.client.model.VaultDto; +import cloud.katta.core.DeviceSetupCallback; +import cloud.katta.crypto.UserKeys; +import cloud.katta.crypto.uvf.UvfMetadataPayload; +import cloud.katta.crypto.uvf.UvfMetadataPayloadPasswordCallback; +import cloud.katta.protocols.hub.exceptions.HubExceptionMappingService; +import cloud.katta.protocols.s3.S3AssumeRoleProtocol; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.nimbusds.jose.JOSEException; /** * Unified vault format (UVF) implementation for Katta @@ -35,20 +57,49 @@ public class HubUVFVault extends UVFVault { private static final Logger log = LogManager.getLogger(HubUVFVault.class); + private final UUID vaultId; + private final UvfMetadataPayload vaultMetadata; + + /** + * Storage connection only available after loading vault + */ private final Session storage; - private final Path home; + private final LoginCallback login; - public HubUVFVault(final Session storage, final Path home) { - super(home); + /** + * + * @param storage Storage connection + * @param vaultId Vault Id + * @param vaultMetadata Vault UVF metadata + * @param prompt Login prompt to access storage + */ + public HubUVFVault(final Session storage, final UUID vaultId, final UvfMetadataPayload vaultMetadata, final LoginCallback prompt) { + super(new Path(vaultMetadata.storage().getDefaultPath(), EnumSet.of(Path.Type.directory, Path.Type.volume), + new PathAttributes().setDisplayname(vaultMetadata.storage().getNickname()))); this.storage = storage; - this.home = home; + this.vaultId = vaultId; + this.vaultMetadata = vaultMetadata; + this.login = prompt; + } + + /** + * + * @return Storage provider configuration + */ + public Session getStorage() { + return storage; } @Override - public T getFeature(final Session ignore, final Class type, final T delegate) throws UnsupportedException { + public T getFeature(final Session hub, final Class type, final T delegate) throws UnsupportedException { log.debug("Delegate to {} for feature {}", storage, type); // Ignore feature implementation but delegate to storage backend - return super.getFeature(storage, type, storage._getFeature(type)); + final T feature = storage._getFeature(type); + if(null == feature) { + log.warn("No feature {} available for {}", type, storage); + throw new UnsupportedException(); + } + return super.getFeature(storage, type, feature); } @Override @@ -63,47 +114,124 @@ public synchronized void close() { super.close(); } + @Override + public Path create(final Session session, final String region, final VaultCredentials noop) throws BackgroundException { + try { + final HubSession hub = HubSession.coerce(session); + log.debug("Created metadata JWE {}", vaultMetadata); + final UvfMetadataPayload.UniversalVaultFormatJWKS jwks = UvfMetadataPayload.createKeys(); + final VaultDto vaultDto = new VaultDto() + .id(vaultId) + .name(vaultMetadata.storage().getNickname()) + .description(null) + .archived(false) + .creationTime(DateTime.now()) + .uvfMetadataFile(vaultMetadata.encrypt( + String.format("%s/api", new HostUrlProvider(false, true).get(session.getHost())), + vaultId, + jwks.toJWKSet() + )) + .uvfKeySet(jwks.serializePublicRecoverykey()); + // Create vault in Hub + final VaultResourceApi vaultResourceApi = new VaultResourceApi(hub.getClient()); + log.debug("Create vault {}", vaultDto); + vaultResourceApi.apiVaultsVaultIdPut(vaultDto.getId(), vaultDto, + storage.getHost().getProtocol().isRoleConfigurable() && !S3Session.isAwsHostname(storage.getHost().getHostname()), + storage.getHost().getProtocol().isRoleConfigurable() && S3Session.isAwsHostname(storage.getHost().getHostname())); + // Upload JWE + log.debug("Grant access to vault {}", vaultDto); + final UserDto userDto = hub.getMe(); + final DeviceSetupCallback setup = login.getFeature(DeviceSetupCallback.class); + final UserKeys userKeys = hub.getUserKeys(setup); + vaultResourceApi.apiVaultsVaultIdAccessTokensPost(vaultDto.getId(), + Collections.singletonMap(userDto.getId(), jwks.toOwnerAccessToken().encryptForUser(userKeys.ecdhKeyPair().getPublic()))); + // Upload vault template to storage + log.debug("Connect to {}", storage); + final Host configuration = storage.getHost(); + // No token exchange with Katta Server + configuration.setProperty(S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE, null); + // Assume role with policy attached to create vault + configuration.setProperty(S3AssumeRoleProtocol.S3_ASSUMEROLE_ROLEARN_WEBIDENTITY, + HostPreferencesFactory.get(storage.getHost()).getProperty(S3AssumeRoleProtocol.S3_ASSUMEROLE_ROLEARN_CREATE_BUCKET)); + // No role chaining when creating vault + configuration.setProperty(S3AssumeRoleProtocol.S3_ASSUMEROLE_ROLEARN_TAG, null); + storage.open(ProxyFactory.get(), new DisabledHostKeyCallback(), login, new DisabledCancelCallback()); + final Path vault; + if(false) { + log.debug("Upload vault template to {}", storage); + return super.create(storage, + HubStorageLocationService.StorageLocation.fromIdentifier(region).getRegion(), noop); + } + else { // Obsolete when implemented in super + final Directory directory = (Directory) storage._getFeature(Directory.class); + final Path home = this.getHome(); + log.debug("Create vault root directory at {}", home); + final TransferStatus status = (new TransferStatus()).setRegion(HubStorageLocationService.StorageLocation.fromIdentifier(region).getRegion()); + vault = directory.mkdir(storage._getFeature(Write.class), home, status); + + final String hashedRootDirId = vaultMetadata.computeRootDirIdHash(); + final Path dataDir = new Path(vault, "d", EnumSet.of(Path.Type.directory)); + final Path firstLevel = new Path(dataDir, hashedRootDirId.substring(0, 2), EnumSet.of(Path.Type.directory)); + final Path secondLevel = new Path(firstLevel, hashedRootDirId.substring(2), EnumSet.of(Path.Type.directory)); + + directory.mkdir(storage._getFeature(Write.class), dataDir, status); + directory.mkdir(storage._getFeature(Write.class), firstLevel, status); + directory.mkdir(storage._getFeature(Write.class), secondLevel, status); + + // vault.uvf + new ContentWriter(storage).write(new Path(home, PreferencesFactory.get().getProperty("cryptomator.vault.config.filename"), + EnumSet.of(Path.Type.file, Path.Type.vault)), vaultDto.getUvfMetadataFile().getBytes(StandardCharsets.US_ASCII)); + // dir.uvf + new ContentWriter(storage).write(new Path(secondLevel, "dir.uvf", EnumSet.of(Path.Type.file)), + vaultMetadata.computeRootDirUvf()); + } + return vault; + } + catch(JOSEException | JsonProcessingException e) { + throw new InteroperabilityException(e.getMessage(), e); + } + catch(ApiException e) { + throw new HubExceptionMappingService().map(e); + } + } + /** - * Upload vault template into existing bucket (permanent credentials) + * + * @param session Hub Connection + * @param prompt Return user keys + * @return Vault configuration with storage connection */ - // TODO https://github.com/shift7-ch/cipherduck-hub/issues/19 review @dko check method signature? - public synchronized Path create(final Session session, final String region, final String metadata, final String hashedRootDirId, final byte[] rootDirUvf) throws BackgroundException { - log.debug("Uploading vault template {} in {} ", home, session.getHost()); - - // N.B. there seems to be no API to check write permissions without actually writing. - if(!session.getFeature(ListService.class).list(home, new DisabledListProgressListener()).isEmpty()) { - throw new BackgroundException("Bucket not empty", String.format("Cannot upload bucket %s in %s is not empty.", home, session.getHost())); + @Override + public HubUVFVault load(final Session session, final PasswordCallback prompt) throws BackgroundException { + try { + log.debug("Connect to {}", storage); + try { + storage.open(ProxyFactory.get(), new DisabledHostKeyCallback(), login, new DisabledCancelCallback()); + } + catch(BackgroundException e) { + log.warn("Skip loading vault with failure {} connecting to storage", e.toString()); + throw new VaultUnlockCancelException(this, e); + } + final Path home = this.getHome(); + home.setAttributes(storage.getFeature(AttributesFinder.class).find(home) + .setDisplayname(vaultMetadata.storage().getNickname())); + log.debug("Initialize vault {} with metadata {}", this, vaultMetadata); + // Initialize cryptors + super.load(storage, new UvfMetadataPayloadPasswordCallback(vaultMetadata.toJSON())); + return this; + } + catch(JsonProcessingException e) { + throw new InteroperabilityException(e.getMessage(), e); } - - // See https://github.com/cryptomator/hub/blob/develop/frontend/src/common/vaultconfig.ts - // zip.file('vault.cryptomator', this.vaultConfigToken); - // zip.folder('d')?.folder(this.rootDirHash.substring(0, 2))?.folder(this.rootDirHash.substring(2)); - - // /vault.uvf - new ContentWriter(session).write(new Path(home, PreferencesFactory.get().getProperty("cryptomator.vault.config.filename"), EnumSet.of(AbstractPath.Type.file, AbstractPath.Type.vault)), metadata.getBytes(StandardCharsets.US_ASCII)); - Directory directory = (Directory) session._getFeature(Directory.class); - - // TODO https://github.com/shift7-ch/cipherduck-hub/issues/19 implement CryptoDirectory for uvf - // Path secondLevel = this.directoryProvider.toEncrypted(session, this.home.attributes().getDirectoryId(), this.home); - final Path secondLevel = new Path(String.format("/%s/d/%s/%s/", session.getHost().getDefaultPath(), hashedRootDirId.substring(0, 2), hashedRootDirId.substring(2)), EnumSet.of(AbstractPath.Type.directory)); - final Path firstLevel = secondLevel.getParent(); - final Path dataDir = firstLevel.getParent(); - log.debug("Create vault root directory at {}", secondLevel); - final TransferStatus status = (new TransferStatus()).setRegion(region); - - directory.mkdir(session._getFeature(Write.class), dataDir, status); - directory.mkdir(session._getFeature(Write.class), firstLevel, status); - directory.mkdir(session._getFeature(Write.class), secondLevel, status); - new ContentWriter(session).write(new Path(secondLevel, "dir.uvf", EnumSet.of(AbstractPath.Type.file)), rootDirUvf); - return home; } @Override - public HubUVFVault load(final Session ignore, final PasswordCallback prompt) throws BackgroundException { - log.debug("Connect to {}", storage); - storage.open(ProxyFactory.get(), new DisabledHostKeyCallback(), new DisabledLoginCallback(), new DisabledCancelCallback()); - storage.login(new DisabledLoginCallback(), new DisabledCancelCallback()); - super.load(storage, prompt); - return this; + public String toString() { + final StringBuilder sb = new StringBuilder("HubUVFVault{"); + sb.append("vaultId=").append(vaultId); + sb.append(", vaultMetadata=").append(vaultMetadata); + sb.append(", storage=").append(storage); + sb.append('}'); + return sb.toString(); } } diff --git a/hub/src/main/java/cloud/katta/protocols/hub/HubVaultListService.java b/hub/src/main/java/cloud/katta/protocols/hub/HubVaultListService.java index c24490ac..c3963ade 100644 --- a/hub/src/main/java/cloud/katta/protocols/hub/HubVaultListService.java +++ b/hub/src/main/java/cloud/katta/protocols/hub/HubVaultListService.java @@ -5,118 +5,92 @@ package cloud.katta.protocols.hub; import ch.cyberduck.core.AttributedList; -import ch.cyberduck.core.Host; import ch.cyberduck.core.ListProgressListener; import ch.cyberduck.core.ListService; -import ch.cyberduck.core.OAuthTokens; +import ch.cyberduck.core.LocaleFactory; +import ch.cyberduck.core.LoginCallback; import ch.cyberduck.core.Path; -import ch.cyberduck.core.PathAttributes; -import ch.cyberduck.core.ProtocolFactory; import ch.cyberduck.core.Session; -import ch.cyberduck.core.SessionFactory; import ch.cyberduck.core.exception.AccessDeniedException; import ch.cyberduck.core.exception.BackgroundException; -import ch.cyberduck.core.exception.InteroperabilityException; import ch.cyberduck.core.exception.NotfoundException; -import ch.cyberduck.core.features.AttributesFinder; -import ch.cyberduck.core.shared.DefaultPathHomeFeature; -import ch.cyberduck.core.ssl.X509KeyManager; -import ch.cyberduck.core.ssl.X509TrustManager; import ch.cyberduck.core.vault.VaultRegistry; +import ch.cyberduck.core.vault.VaultUnlockCancelException; import org.apache.http.HttpStatus; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.cryptomator.cryptolib.api.UVFMasterkey; -import java.util.EnumSet; +import java.text.MessageFormat; import cloud.katta.client.ApiException; -import cloud.katta.client.api.ConfigResourceApi; import cloud.katta.client.api.VaultResourceApi; -import cloud.katta.client.model.ConfigDto; import cloud.katta.client.model.VaultDto; -import cloud.katta.crypto.DeviceKeys; -import cloud.katta.crypto.UserKeys; +import cloud.katta.core.DeviceSetupCallback; import cloud.katta.crypto.uvf.UvfMetadataPayload; -import cloud.katta.crypto.uvf.UvfMetadataPayloadPasswordCallback; import cloud.katta.protocols.hub.exceptions.HubExceptionMappingService; -import cloud.katta.workflows.DeviceKeysServiceImpl; -import cloud.katta.workflows.UserKeysServiceImpl; import cloud.katta.workflows.VaultServiceImpl; import cloud.katta.workflows.exceptions.AccessException; import cloud.katta.workflows.exceptions.SecurityFailure; -import com.fasterxml.jackson.core.JsonProcessingException; public class HubVaultListService implements ListService { private static final Logger log = LogManager.getLogger(HubVaultListService.class); - private final ProtocolFactory protocols; private final HubSession session; - private final X509TrustManager trust; - private final X509KeyManager key; - private final VaultRegistry registry; - private final OAuthTokens tokens; + private final LoginCallback prompt; - public HubVaultListService(final ProtocolFactory protocols, final HubSession session, - final X509TrustManager trust, final X509KeyManager key, final VaultRegistry registry, final OAuthTokens tokens) { - this.protocols = protocols; + public HubVaultListService(final HubSession session, final LoginCallback prompt) { this.session = session; - this.trust = trust; - this.key = key; - this.registry = registry; - this.tokens = tokens; + this.prompt = prompt; } @Override public AttributedList list(final Path directory, final ListProgressListener listener) throws BackgroundException { if(directory.isRoot()) { try { - final ConfigDto configDto = new ConfigResourceApi(session.getClient()).apiConfigGet(); - log.debug("Read configuration {}", configDto); + final VaultServiceImpl vaultService = new VaultServiceImpl(session); + final VaultRegistry registry = session.getRegistry(); final AttributedList vaults = new AttributedList<>(); for(final VaultDto vaultDto : new VaultResourceApi(session.getClient()).apiVaultsAccessibleGet(null)) { if(Boolean.TRUE.equals(vaultDto.getArchived())) { log.debug("Skip archived vault {}", vaultDto); continue; } - final DeviceKeys deviceKeys = new DeviceKeysServiceImpl().getDeviceKeys(session.getHost()); - final UserKeys userKeys = new UserKeysServiceImpl(session).getUserKeys(session.getHost(), session.getMe(), deviceKeys); - log.debug("Read vault {}", vaultDto); - // Find storage configuration in vault metadata - final VaultServiceImpl vaultService = new VaultServiceImpl(session); - final UvfMetadataPayload vaultMetadata; + log.debug("Load vault {}", vaultDto); try { - vaultMetadata = vaultService.getVaultMetadataJWE(vaultDto.getId(), userKeys); + // Find storage configuration in vault metadata + final DeviceSetupCallback setup = prompt.getFeature(DeviceSetupCallback.class); + final UvfMetadataPayload vaultMetadata = vaultService.getVaultMetadataJWE(vaultDto.getId(), session.getUserKeys(setup)); + final Session storage = vaultService.getVaultStorageSession(session, vaultDto.getId(), vaultMetadata); + final HubUVFVault vault = new HubUVFVault(storage, vaultDto.getId(), vaultMetadata, prompt); + try { + registry.add(vault.load(session, prompt)); + vaults.add(vault.getHome()); + listener.chunk(directory, vaults); + } + catch(VaultUnlockCancelException e) { + log.warn("Skip vault {} with failure {} loading", vaultDto, e); + } } catch(ApiException e) { if(HttpStatus.SC_FORBIDDEN == e.getCode()) { - log.warn("Skip vault {} with insufficient permissions", vaultDto); + log.warn("Skip vault {} with insufficient permissions {}", vaultDto, e); continue; } throw e; } - final Host bookmark = vaultService.getStorageBackend(protocols, session, configDto, vaultDto.getId(), - vaultMetadata.storage(), tokens); - log.debug("Configured {} for vault {}", bookmark, vaultDto); - final Session storage = SessionFactory.create(bookmark, trust, key); - final HubUVFVault vault = new HubUVFVault(storage, new DefaultPathHomeFeature(bookmark).find()); - registry.add(vault.load(session, new UvfMetadataPayloadPasswordCallback(vaultMetadata))); - final PathAttributes attr = storage.getFeature(AttributesFinder.class).find(vault.getHome()); - try (UVFMasterkey masterKey = UVFMasterkey.fromDecryptedPayload(vaultMetadata.toJSON())) { - attr.setDirectoryId(masterKey.rootDirId()); + catch(AccessException e) { + log.warn("Skip vault {} with access failure {}", vaultDto, e); + } + catch(SecurityFailure e) { + throw new AccessDeniedException(e.getMessage(), e); } - vaults.add(new Path(vault.getHome()).withType(EnumSet.of(Path.Type.volume, Path.Type.directory)) - .withAttributes(attr)); } return vaults; } catch(ApiException e) { throw new HubExceptionMappingService().map("Listing directory {0} failed", e, directory); } - catch(SecurityFailure | AccessException | JsonProcessingException e) { - throw new InteroperabilityException(e.getMessage()); - } } throw new NotfoundException(directory.getAbsolute()); } @@ -126,10 +100,11 @@ public void preflight(final Path directory) throws BackgroundException { if(directory.isRoot()) { return; } - if(registry.contains(directory)) { + if(session.getRegistry().contains(directory)) { return; } log.warn("Deny directory listing with no vault available for {}", directory); - throw new AccessDeniedException(); + throw new AccessDeniedException(MessageFormat.format(LocaleFactory.localizedString("Listing directory {0} failed", "Error"), + directory.getName())).withFile(directory); } } diff --git a/hub/src/main/java/cloud/katta/protocols/hub/HubVaultRegistry.java b/hub/src/main/java/cloud/katta/protocols/hub/HubVaultRegistry.java index 789824a4..223acd25 100644 --- a/hub/src/main/java/cloud/katta/protocols/hub/HubVaultRegistry.java +++ b/hub/src/main/java/cloud/katta/protocols/hub/HubVaultRegistry.java @@ -4,35 +4,28 @@ package cloud.katta.protocols.hub; -import ch.cyberduck.core.DefaultPathContainerService; import ch.cyberduck.core.DisabledPasswordCallback; -import ch.cyberduck.core.Path; +import ch.cyberduck.core.PasswordCallback; import ch.cyberduck.core.Session; import ch.cyberduck.core.features.Vault; import ch.cyberduck.core.vault.DefaultVaultRegistry; -import org.apache.commons.lang3.StringUtils; - public class HubVaultRegistry extends DefaultVaultRegistry { public HubVaultRegistry() { - super(new DisabledPasswordCallback()); + this(new DisabledPasswordCallback()); } - @Override - public Vault find(final Session session, final Path file, final boolean unlock) { - for(final Vault vault : this) { - if(StringUtils.equals(new DefaultPathContainerService().getContainer(file).getName(), - new DefaultPathContainerService().getContainer(vault.getHome()).getName())) { - // Return matching vault - return vault; - } - } - return Vault.DISABLED; + public HubVaultRegistry(final PasswordCallback prompt) { + super(prompt); + } + + public HubVaultRegistry(final PasswordCallback prompt, final Vault... vaults) { + super(prompt, vaults); } @Override - public T getFeature(Session session, Class type, T proxy) { + public T getFeature(final Session session, final Class type, final T proxy) { // Always forward to load feature from vault return this._getFeature(session, type, proxy); } diff --git a/hub/src/main/java/cloud/katta/protocols/hub/HubVaultStorageAwareComparisonService.java b/hub/src/main/java/cloud/katta/protocols/hub/HubVaultStorageAwareComparisonService.java new file mode 100644 index 00000000..703ec312 --- /dev/null +++ b/hub/src/main/java/cloud/katta/protocols/hub/HubVaultStorageAwareComparisonService.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.protocols.hub; + +import ch.cyberduck.core.Path; +import ch.cyberduck.core.PathAttributes; +import ch.cyberduck.core.features.Vault; +import ch.cyberduck.core.synchronization.Comparison; +import ch.cyberduck.core.synchronization.ComparisonService; +import ch.cyberduck.core.vault.VaultUnlockCancelException; + +public class HubVaultStorageAwareComparisonService implements ComparisonService { + + private final HubSession session; + + public HubVaultStorageAwareComparisonService(final HubSession session) { + this.session = session; + } + + @Override + public Comparison compare(final Path.Type type, final PathAttributes local, final PathAttributes remote) { + try { + final ComparisonService feature = this.getFeature(remote.getVault()); + return feature.compare(type, local, remote); + } + catch(VaultUnlockCancelException e) { + return Comparison.unknown; + } + } + + @Override + public int hashCode(final Path.Type type, final PathAttributes attr) { + try { + final ComparisonService feature = this.getFeature(attr.getVault()); + return feature.hashCode(type, attr); + } + catch(VaultUnlockCancelException e) { + return 0; + } + } + + private ComparisonService getFeature(final Path vault) throws VaultUnlockCancelException { + if(null == vault) { + return ComparisonService.disabled; + } + final Vault impl = session.getRegistry().find(session, vault); + if(impl instanceof HubUVFVault) { + final HubUVFVault cryptomator = (HubUVFVault) impl; + return cryptomator.getStorage().getFeature(ComparisonService.class); + } + // Disabled + return ComparisonService.disabled; + } +} diff --git a/hub/src/main/java/cloud/katta/protocols/hub/exceptions/HubExceptionMappingService.java b/hub/src/main/java/cloud/katta/protocols/hub/exceptions/HubExceptionMappingService.java index 2ede6c73..d7a27ef8 100644 --- a/hub/src/main/java/cloud/katta/protocols/hub/exceptions/HubExceptionMappingService.java +++ b/hub/src/main/java/cloud/katta/protocols/hub/exceptions/HubExceptionMappingService.java @@ -32,6 +32,7 @@ public BackgroundException map(final String message, final ApiException failure, @Override public BackgroundException map(final ApiException failure) { + log.warn("Map failure {}", failure.toString()); for(final Throwable cause : ExceptionUtils.getThrowableList(failure)) { if(cause instanceof SocketException) { // Map Connection has been shutdown: javax.net.ssl.SSLException: java.net.SocketException: Broken pipe diff --git a/hub/src/main/java/cloud/katta/protocols/hub/serializer/HubConfigDtoDeserializer.java b/hub/src/main/java/cloud/katta/protocols/hub/serializer/HubConfigDtoDeserializer.java index 0a138240..3b8d2895 100644 --- a/hub/src/main/java/cloud/katta/protocols/hub/serializer/HubConfigDtoDeserializer.java +++ b/hub/src/main/java/cloud/katta/protocols/hub/serializer/HubConfigDtoDeserializer.java @@ -4,6 +4,8 @@ package cloud.katta.protocols.hub.serializer; +import ch.cyberduck.core.serializer.Deserializer; + import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -18,11 +20,15 @@ public class HubConfigDtoDeserializer extends ProxyDeserializer { private final ConfigDto dto; + public HubConfigDtoDeserializer(final ConfigDto dto) { + this(dto, ProxyDeserializer.empty()); + } + /** * @param dto Hub configuration */ - public HubConfigDtoDeserializer(final ConfigDto dto) { - super(ProxyDeserializer.empty()); + public HubConfigDtoDeserializer(final ConfigDto dto, final Deserializer parent) { + super(parent); this.dto = dto; } diff --git a/hub/src/main/java/cloud/katta/protocols/hub/serializer/ProxyDeserializer.java b/hub/src/main/java/cloud/katta/protocols/hub/serializer/ProxyDeserializer.java index 77a0aac2..f3676116 100644 --- a/hub/src/main/java/cloud/katta/protocols/hub/serializer/ProxyDeserializer.java +++ b/hub/src/main/java/cloud/katta/protocols/hub/serializer/ProxyDeserializer.java @@ -56,32 +56,32 @@ public static Deserializer empty() { return new Deserializer() { @Override public String stringForKey(final String key) { - log.warn("Unknown key {}", key); + log.trace("Unknown key {}", key); return null; } @Override public T objectForKey(final String key) { - log.warn("Unknown key {}", key); + log.trace("Unknown key {}", key); return null; } @Override public List listForKey(final String key) { - log.warn("Unknown key {}", key); + log.trace("Unknown key {}", key); return Collections.emptyList(); } @Override public Map mapForKey(final String key) { - log.warn("Unknown key {}", key); + log.trace("Unknown key {}", key); return Collections.emptyMap(); } @Override public Boolean booleanForKey(final String key) { - log.warn("Unknown key {}", key); - return false; + log.trace("Unknown key {}", key); + return null; } @Override diff --git a/hub/src/main/java/cloud/katta/protocols/hub/serializer/StorageProfileDtoWrapperDeserializer.java b/hub/src/main/java/cloud/katta/protocols/hub/serializer/StorageProfileDtoWrapperDeserializer.java index c85602e4..8251dc16 100644 --- a/hub/src/main/java/cloud/katta/protocols/hub/serializer/StorageProfileDtoWrapperDeserializer.java +++ b/hub/src/main/java/cloud/katta/protocols/hub/serializer/StorageProfileDtoWrapperDeserializer.java @@ -25,10 +25,14 @@ public class StorageProfileDtoWrapperDeserializer extends ProxyDeserializer parent, final StorageProfileDtoWrapper dto) { + public StorageProfileDtoWrapperDeserializer(final StorageProfileDtoWrapper dto, final Deserializer parent) { super(parent); this.dto = dto; } @@ -49,7 +53,7 @@ public List listForKey(final String key) { } if(dto.getProtocol() == Protocol.S3_STS) { properties.add(String.format("%s=%s", S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE, true)); - properties.add(String.format("%s=%s", S3AssumeRoleProtocol.S3_ASSUMEROLE_ROLEARN, dto.getStsRoleArn())); + properties.add(String.format("%s=%s", S3AssumeRoleProtocol.S3_ASSUMEROLE_ROLEARN_WEBIDENTITY, dto.getStsRoleArn())); if(dto.getStsRoleArn2() != null) { properties.add(String.format("%s=%s", S3AssumeRoleProtocol.S3_ASSUMEROLE_ROLEARN_TAG, dto.getStsRoleArn2())); } @@ -60,6 +64,7 @@ public List listForKey(final String key) { properties.add(String.format("%s=%s", S3AssumeRoleProtocol.S3_ASSUMEROLE_DURATIONSECONDS, dto.getStsDurationSeconds().toString())); } } + properties.add("s3.assumerole.rolearn.tag.vaultid.key=Vault"); log.debug("Return properties {} from {}", properties, dto); return (List) properties; case REGIONS_KEY: @@ -81,6 +86,8 @@ public String stringForKey(final String key) { break; case VENDOR_KEY: return dto.getId().toString(); + case DEFAULT_NICKNAME_KEY: + return dto.getName(); case SCHEME_KEY: return dto.getScheme(); case DEFAULT_HOSTNAME_KEY: @@ -106,6 +113,10 @@ public Boolean booleanForKey(final String key) { return true; } break; + case ROLE_KEY_CONFIGURABLE_KEY: + // Indicates Role ARN is required for STS `AssumeRoleWithWebIdentity`. + // Determines usage of role grant flags when creating a new vault + return dto.getStsRoleArn() != null; } return super.booleanForKey(key); } @@ -119,6 +130,9 @@ public List keys() { PROPERTIES_KEY, OAUTH_CONFIGURABLE_KEY) ); + if(dto.getName() != null) { + keys.add(DEFAULT_NICKNAME_KEY); + } if(dto.getScheme() != null) { keys.add(SCHEME_KEY); } @@ -137,6 +151,9 @@ public List keys() { if(dto.getRegions() != null) { keys.add(REGIONS_KEY); } + if(dto.getStsRoleArn() != null) { + keys.add(ROLE_KEY_CONFIGURABLE_KEY); + } return keys; } } diff --git a/hub/src/main/java/cloud/katta/protocols/s3/S3AssumeRoleProtocol.java b/hub/src/main/java/cloud/katta/protocols/s3/S3AssumeRoleProtocol.java index c274d1b8..de264e84 100644 --- a/hub/src/main/java/cloud/katta/protocols/s3/S3AssumeRoleProtocol.java +++ b/hub/src/main/java/cloud/katta/protocols/s3/S3AssumeRoleProtocol.java @@ -16,26 +16,14 @@ public class S3AssumeRoleProtocol extends S3Protocol { // Token exchange public static final String OAUTH_TOKENEXCHANGE = "oauth.tokenexchange"; - public static final String OAUTH_TOKENEXCHANGE_VAULT = "oauth.tokenexchange.vault"; - public static final String OAUTH_TOKENEXCHANGE_BASEPATH = "oauth.tokenexchange.basepath"; // STS assume role with web identity resource name - public static final String S3_ASSUMEROLE_ROLEARN = Profile.STS_ROLE_ARN_PROPERTY_KEY; + public static final String S3_ASSUMEROLE_ROLEARN_WEBIDENTITY = Profile.STS_ROLE_ARN_PROPERTY_KEY; public static final String S3_ASSUMEROLE_DURATIONSECONDS = Profile.STS_DURATION_SECONDS_PROPERTY_KEY; // STS assume role chaining (AWS only) public static final String S3_ASSUMEROLE_ROLEARN_TAG = "s3.assumerole.rolearn.tag"; public static final String S3_ASSUMEROLE_ROLEARN_CREATE_BUCKET = "s3.assumerole.rolearn.createbucket"; - private final String authorization; - - public S3AssumeRoleProtocol() { - this("AuthorizationCode"); - } - - public S3AssumeRoleProtocol(final String authorization) { - this.authorization = authorization; - } - @Override public String getIdentifier() { return "s3-assumerole"; @@ -51,11 +39,6 @@ public String getPrefix() { return String.format("%s.%s", S3AssumeRoleProtocol.class.getPackage().getName(), "S3AssumeRole"); } - @Override - public String getAuthorization() { - return authorization; - } - @Override @SuppressWarnings("unchecked") public T getFeature(final Class type) { diff --git a/hub/src/main/java/cloud/katta/protocols/s3/S3AssumeRoleSession.java b/hub/src/main/java/cloud/katta/protocols/s3/S3AssumeRoleSession.java index 9207f7a5..e7e4db43 100644 --- a/hub/src/main/java/cloud/katta/protocols/s3/S3AssumeRoleSession.java +++ b/hub/src/main/java/cloud/katta/protocols/s3/S3AssumeRoleSession.java @@ -6,9 +6,7 @@ import ch.cyberduck.core.Host; import ch.cyberduck.core.LoginCallback; -import ch.cyberduck.core.OAuthTokens; import ch.cyberduck.core.exception.LoginCanceledException; -import ch.cyberduck.core.oauth.OAuth2AuthorizationService; import ch.cyberduck.core.oauth.OAuth2RequestInterceptor; import ch.cyberduck.core.s3.S3CredentialsStrategy; import ch.cyberduck.core.s3.S3Session; @@ -20,11 +18,25 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.util.UUID; + +import cloud.katta.protocols.hub.HubSession; + public class S3AssumeRoleSession extends S3Session { private static final Logger log = LogManager.getLogger(S3AssumeRoleSession.class); - public S3AssumeRoleSession(final Host host, final X509TrustManager trust, final X509KeyManager key) { - super(host, trust, key); + private final HubSession hub; + /** + * Shared OAuth tokens + */ + private final OAuth2RequestInterceptor oauth; + private final UUID vaultId; + + public S3AssumeRoleSession(final HubSession hub, final UUID vaultId, final Host host) { + super(host, hub.getFeature(X509TrustManager.class), hub.getFeature(X509KeyManager.class)); + this.hub = hub; + this.oauth = hub.getFeature(OAuth2RequestInterceptor.class); + this.vaultId = vaultId; } /** @@ -38,20 +50,9 @@ public S3AssumeRoleSession(final Host host, final X509TrustManager trust, final @Override protected S3CredentialsStrategy configureCredentialsStrategy(final HttpClientBuilder configuration, final LoginCallback prompt) throws LoginCanceledException { if(host.getProtocol().isOAuthConfigurable()) { - // Shared OAuth tokens - final OAuth2RequestInterceptor oauth = new OAuth2RequestInterceptor(configuration.build(), host, prompt); - oauth.withRedirectUri(host.getProtocol().getOAuthRedirectUrl()); - if(host.getProtocol().getAuthorization() != null) { - oauth.withFlowType(OAuth2AuthorizationService.FlowType.valueOf(host.getProtocol().getAuthorization())); - } log.debug("Register interceptor {}", oauth); configuration.addInterceptorLast(oauth); - final STSRequestInterceptor sts = new STSChainedAssumeRoleRequestInterceptor(oauth, host, trust, key, prompt) { - @Override - protected String getWebIdentityToken(final OAuthTokens oauth) { - return oauth.getAccessToken(); - } - }; + final STSRequestInterceptor sts = new STSChainedAssumeRoleRequestInterceptor(hub, oauth, vaultId, host, trust, key, prompt); log.debug("Register interceptor {}", sts); configuration.addInterceptorLast(sts); return sts; diff --git a/hub/src/main/java/cloud/katta/protocols/s3/STSChainedAssumeRoleRequestInterceptor.java b/hub/src/main/java/cloud/katta/protocols/s3/STSChainedAssumeRoleRequestInterceptor.java index 7b26d910..f585749e 100644 --- a/hub/src/main/java/cloud/katta/protocols/s3/STSChainedAssumeRoleRequestInterceptor.java +++ b/hub/src/main/java/cloud/katta/protocols/s3/STSChainedAssumeRoleRequestInterceptor.java @@ -10,6 +10,7 @@ import ch.cyberduck.core.Profile; import ch.cyberduck.core.TemporaryAccessTokens; import ch.cyberduck.core.exception.BackgroundException; +import ch.cyberduck.core.exception.InteroperabilityException; import ch.cyberduck.core.oauth.OAuth2RequestInterceptor; import ch.cyberduck.core.preferences.HostPreferencesFactory; import ch.cyberduck.core.preferences.PreferencesReader; @@ -21,6 +22,8 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.util.UUID; + import cloud.katta.client.ApiException; import cloud.katta.client.api.StorageResourceApi; import cloud.katta.client.model.AccessTokenResponse; @@ -39,13 +42,22 @@ public class STSChainedAssumeRoleRequestInterceptor extends STSAssumeRoleWithWeb */ private static final String OIDC_AUTHORIZED_PARTY = "azp"; + private final HubSession hub; private final Host bookmark; + private final UUID vaultId; - public STSChainedAssumeRoleRequestInterceptor(final OAuth2RequestInterceptor oauth, final Host host, + public STSChainedAssumeRoleRequestInterceptor(final HubSession hub, final OAuth2RequestInterceptor oauth, final UUID vaultId, final Host host, final X509TrustManager trust, final X509KeyManager key, final LoginCallback prompt) { super(oauth, host, trust, key, prompt); + this.hub = hub; this.bookmark = host; + this.vaultId = vaultId; + } + + @Override + protected String getWebIdentityToken(final OAuthTokens oauth) { + return oauth.getAccessToken(); } /** @@ -61,13 +73,16 @@ public STSChainedAssumeRoleRequestInterceptor(final OAuth2RequestInterceptor oau @Override public TemporaryAccessTokens assumeRoleWithWebIdentity(final OAuthTokens oauth, final String roleArn) throws BackgroundException { final PreferencesReader settings = HostPreferencesFactory.get(bookmark); - final TemporaryAccessTokens tokens = super.assumeRoleWithWebIdentity(this.tokenExchange(oauth), settings.getProperty(S3AssumeRoleProtocol.S3_ASSUMEROLE_ROLEARN)); + final TemporaryAccessTokens tokens = super.assumeRoleWithWebIdentity(this.tokenExchange(oauth), settings.getProperty(S3AssumeRoleProtocol.S3_ASSUMEROLE_ROLEARN_WEBIDENTITY)); if(StringUtils.isNotBlank(settings.getProperty(S3AssumeRoleProtocol.S3_ASSUMEROLE_ROLEARN_TAG))) { log.debug("Assume role with temporary credentials {}", tokens); // Assume role with previously obtained temporary access token + final String key = HostPreferencesFactory.get(bookmark).getProperty("s3.assumerole.rolearn.tag.vaultid.key"); + if(null == key) { + throw new InteroperabilityException("No vault tag key set"); + } return super.assumeRole(credentials.setTokens(tokens) - .setProperty(Profile.STS_TAGS_PROPERTY_KEY, String.format("%s=%s", HostPreferencesFactory.get(bookmark).getProperty("s3.assumerole.rolearn.tag.vaultid.key"), - settings.getProperty(S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE_VAULT))), + .setProperty(Profile.STS_TAGS_PROPERTY_KEY, String.format("%s=%s", key, vaultId)), settings.getProperty(S3AssumeRoleProtocol.S3_ASSUMEROLE_ROLEARN_TAG)); } log.warn("No vault tag set. Skip assuming role with temporary credentials {} for {}", tokens, bookmark); @@ -78,17 +93,14 @@ public TemporaryAccessTokens assumeRoleWithWebIdentity(final OAuthTokens oauth, * Perform OAuth 2.0 Token Exchange * * @return New tokens - * @see S3AssumeRoleProtocol#OAUTH_TOKENEXCHANGE_VAULT */ private OAuthTokens tokenExchange(final OAuthTokens tokens) throws BackgroundException { final PreferencesReader settings = HostPreferencesFactory.get(bookmark); if(settings.getBoolean(S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE)) { - log.info("Exchange tokens for {}", bookmark); - final HubSession hub = bookmark.getProtocol().getFeature(HubSession.class); - log.debug("Exchange token with hub {}", hub); + log.info("Exchange tokens {} for vault {}", tokens, vaultId); final StorageResourceApi api = new StorageResourceApi(hub.getClient()); try { - final AccessTokenResponse tokenExchangeResponse = api.apiStorageS3TokenPost(settings.getProperty(S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE_VAULT)); + final AccessTokenResponse tokenExchangeResponse = api.apiStorageS3TokenPost(vaultId.toString()); // N.B. token exchange with Id token does not work! final OAuthTokens exchanged = new OAuthTokens(tokenExchangeResponse.getAccessToken(), tokenExchangeResponse.getRefreshToken(), diff --git a/hub/src/main/java/cloud/katta/workflows/CachingUserKeysService.java b/hub/src/main/java/cloud/katta/workflows/CachingUserKeysService.java deleted file mode 100644 index 0d149427..00000000 --- a/hub/src/main/java/cloud/katta/workflows/CachingUserKeysService.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2025 shift7 GmbH. All rights reserved. - */ - -package cloud.katta.workflows; - -import ch.cyberduck.core.Host; - -import cloud.katta.client.ApiException; -import cloud.katta.client.model.UserDto; -import cloud.katta.core.DeviceSetupCallback; -import cloud.katta.crypto.DeviceKeys; -import cloud.katta.crypto.UserKeys; -import cloud.katta.workflows.exceptions.AccessException; -import cloud.katta.workflows.exceptions.SecurityFailure; - -/** - * Retrieve user keys from hub upon first access and cache in memory during service's lifetime. - */ -public class CachingUserKeysService implements UserKeysService { - - private final UserKeysService proxy; - private UserKeys userKeys; - - public CachingUserKeysService(final UserKeysService proxy) { - this.proxy = proxy; - } - - /** - * Get user key from hub and decrypt with device-keys - */ - public UserKeys getUserKeys(final Host hub, final UserDto me, final DeviceKeys deviceKeyPair) throws ApiException, AccessException, SecurityFailure { - // Get user key from hub and decrypt with device-keys - if(userKeys == null) { - userKeys = proxy.getUserKeys(hub, me, deviceKeyPair); - } - return userKeys; - } - - @Override - public UserKeys getOrCreateUserKeys(final Host hub, final UserDto me, final DeviceKeys deviceKeyPair, final DeviceSetupCallback prompt) throws ApiException, AccessException, SecurityFailure { - if(userKeys == null) { - userKeys = proxy.getOrCreateUserKeys(hub, me, deviceKeyPair, prompt); - } - return userKeys; - } -} diff --git a/hub/src/main/java/cloud/katta/workflows/CachingWoTService.java b/hub/src/main/java/cloud/katta/workflows/CachingWoTService.java deleted file mode 100644 index cf901f90..00000000 --- a/hub/src/main/java/cloud/katta/workflows/CachingWoTService.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2025 shift7 GmbH. All rights reserved. - */ - -package cloud.katta.workflows; - -import java.text.ParseException; -import java.util.List; -import java.util.Map; - -import cloud.katta.client.ApiException; -import cloud.katta.client.model.TrustedUserDto; -import cloud.katta.client.model.UserDto; -import cloud.katta.crypto.UserKeys; -import cloud.katta.crypto.wot.SignedKeys; -import cloud.katta.workflows.exceptions.AccessException; -import cloud.katta.workflows.exceptions.SecurityFailure; -import com.nimbusds.jose.JOSEException; - -/** - * Retrieve verified trusted user from hub upon first access and cache afterwards. - * Counterpart of @see wot.ts. - */ -public class CachingWoTService implements WoTService { - - private final WoTService proxy; - - private Map trustLevels; - - public CachingWoTService(final WoTService proxy) { - this.proxy = proxy; - } - - @Override - public Map getTrustLevelsPerUserId(final UserKeys userKeys) throws ApiException, AccessException, SecurityFailure { - if(trustLevels == null) { - trustLevels = proxy.getTrustLevelsPerUserId(userKeys); - } - return trustLevels; - } - - @Override - public void verify(final UserKeys userKeys, final List signatureChain, final SignedKeys allegedSignedKey) throws ApiException, AccessException, SecurityFailure { - proxy.verify(userKeys, signatureChain, allegedSignedKey); - } - - @Override - public TrustedUserDto sign(final UserKeys userKeys, final UserDto user) throws ApiException, ParseException, JOSEException, AccessException, SecurityFailure { - return proxy.sign(userKeys, user); - } -} diff --git a/hub/src/main/java/cloud/katta/workflows/CreateVaultService.java b/hub/src/main/java/cloud/katta/workflows/CreateVaultService.java deleted file mode 100644 index 7098184f..00000000 --- a/hub/src/main/java/cloud/katta/workflows/CreateVaultService.java +++ /dev/null @@ -1,318 +0,0 @@ -/* - * Copyright (c) 2025 shift7 GmbH. All rights reserved. - */ - -package cloud.katta.workflows; - -import ch.cyberduck.core.DisabledCancelCallback; -import ch.cyberduck.core.DisabledHostKeyCallback; -import ch.cyberduck.core.DisabledLoginCallback; -import ch.cyberduck.core.Host; -import ch.cyberduck.core.HostPasswordStore; -import ch.cyberduck.core.HostUrlProvider; -import ch.cyberduck.core.OAuthTokens; -import ch.cyberduck.core.PasswordStoreFactory; -import ch.cyberduck.core.Path; -import ch.cyberduck.core.ProtocolFactory; -import ch.cyberduck.core.TemporaryAccessTokens; -import ch.cyberduck.core.exception.BackgroundException; -import ch.cyberduck.core.proxy.DisabledProxyFinder; -import ch.cyberduck.core.s3.S3Session; - -import org.apache.commons.io.IOUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.joda.time.DateTime; - -import java.io.IOException; -import java.nio.charset.Charset; -import java.util.Base64; -import java.util.Collections; -import java.util.EnumSet; -import java.util.UUID; - -import cloud.katta.client.ApiException; -import cloud.katta.client.api.ConfigResourceApi; -import cloud.katta.client.api.StorageProfileResourceApi; -import cloud.katta.client.api.StorageResourceApi; -import cloud.katta.client.api.UsersResourceApi; -import cloud.katta.client.api.VaultResourceApi; -import cloud.katta.client.model.CreateS3STSBucketDto; -import cloud.katta.client.model.Protocol; -import cloud.katta.client.model.UserDto; -import cloud.katta.client.model.VaultDto; -import cloud.katta.crypto.UserKeys; -import cloud.katta.crypto.uvf.UvfMetadataPayload; -import cloud.katta.crypto.uvf.VaultMetadataJWEAutomaticAccessGrantDto; -import cloud.katta.crypto.uvf.VaultMetadataJWEBackendDto; -import cloud.katta.model.StorageProfileDtoWrapper; -import cloud.katta.protocols.hub.HubSession; -import cloud.katta.protocols.hub.HubUVFVault; -import cloud.katta.workflows.exceptions.AccessException; -import cloud.katta.workflows.exceptions.SecurityFailure; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.AnonymousAWSCredentials; -import com.amazonaws.client.builder.AwsClientBuilder; -import com.amazonaws.services.securitytoken.AWSSecurityTokenService; -import com.amazonaws.services.securitytoken.AWSSecurityTokenServiceClientBuilder; -import com.amazonaws.services.securitytoken.model.AssumeRoleWithWebIdentityRequest; -import com.amazonaws.services.securitytoken.model.AssumeRoleWithWebIdentityResult; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.nimbusds.jose.JOSEException; - -/** - * Create a vault in hub from CreateVaultModel. - */ -public class CreateVaultService { - private static final Logger log = LogManager.getLogger(CreateVaultService.class); - - private final HubSession hubSession; - private final ConfigResourceApi configResource; - private final VaultResourceApi vaultResource; - private final StorageProfileResourceApi storageProfileResource; - private final StorageResourceApi storageResource; - private final UsersResourceApi users; - private final TemplateUploadService templateUploadService; - private final STSInlinePolicyService stsInlinePolicyService; - - public CreateVaultService(final HubSession hubSession) { - this(hubSession, new ConfigResourceApi(hubSession.getClient()), new VaultResourceApi(hubSession.getClient()), new StorageProfileResourceApi(hubSession.getClient()), new UsersResourceApi(hubSession.getClient()), new StorageResourceApi(hubSession.getClient()), new TemplateUploadService(), new STSInlinePolicyService()); - } - - CreateVaultService(final HubSession hubSession, final ConfigResourceApi configResource, final VaultResourceApi vaultResource, final StorageProfileResourceApi storageProfileResource, final UsersResourceApi users, final StorageResourceApi storageResource, final TemplateUploadService templateUploadService, final STSInlinePolicyService stsInlinePolicyService) { - this.hubSession = hubSession; - this.configResource = configResource; - this.vaultResource = vaultResource; - this.storageProfileResource = storageProfileResource; - this.storageResource = storageResource; - this.users = users; - this.templateUploadService = templateUploadService; - this.stsInlinePolicyService = stsInlinePolicyService; - } - - public void createVault(final UserKeys userKeys, final StorageProfileDtoWrapper storageProfileWrapper, final CreateVaultModel vaultModel) throws ApiException, AccessException, SecurityFailure, BackgroundException { - try { - final UvfMetadataPayload.UniversalVaultFormatJWKS jwks = UvfMetadataPayload.createKeys(); - final UvfMetadataPayload metadataPayload = UvfMetadataPayload.create() - .withStorage(new VaultMetadataJWEBackendDto() - .provider(storageProfileWrapper.getId().toString()) - .defaultPath(storageProfileWrapper.getProtocol() == Protocol.S3_STS ? storageProfileWrapper.getBucketPrefix() + vaultModel.vaultId() : vaultModel.bucketName()) - .region(vaultModel.region()) - .nickname(vaultModel.vaultName()) - .username(vaultModel.accessKeyId()) - .password(vaultModel.secretKey())) - .withAutomaticAccessGrant(new VaultMetadataJWEAutomaticAccessGrantDto() - .enabled(vaultModel.automaticAccessGrant()) - .maxWotDepth(vaultModel.maxWotLevel()) - ); - log.debug("Created metadata JWE {}", metadataPayload); - final String uvfMetadataFile = metadataPayload.encrypt( - String.format("%s/api", new HostUrlProvider(false, true).get(hubSession.getHost())), - vaultModel.vaultId(), - jwks.toJWKSet() - ); - final VaultDto vaultDto = new VaultDto() - .id(vaultModel.vaultId()) - .name(metadataPayload.storage().getNickname()) - .description(vaultModel.vaultDescription()) - .archived(false) - .creationTime(DateTime.now()) - .uvfMetadataFile(uvfMetadataFile) - .uvfKeySet(jwks.serializePublicRecoverykey()); - - // create storage dto - final String hashedRootDirId = metadataPayload.computeRootDirIdHash(); - final CreateS3STSBucketDto storageDto = new CreateS3STSBucketDto() - .vaultId(vaultModel.vaultId().toString()) - .storageConfigId(storageProfileWrapper.getId()) - .vaultUvf(uvfMetadataFile) - .rootDirHash(hashedRootDirId) - .dirUvf(Base64.getUrlEncoder().encodeToString(metadataPayload.computeRootDirUvf())) - .region(metadataPayload.storage().getRegion()); - log.debug("Created storage dto {}", storageDto); - - // (1) create vault in hub, incl. Keycloak sync - final boolean minio = storageProfileWrapper.getStsRoleArn() != null && storageProfileWrapper.getStsRoleArn2() == null; - final boolean aws = storageProfileWrapper.getStsRoleArn() != null && storageProfileWrapper.getStsRoleArn2() != null; - log.debug("Create vault {}, minio={}, aws={}", vaultDto, minio, aws); - vaultResource.apiVaultsVaultIdPut(vaultDto.getId(), vaultDto, minio, aws); - - // (2) create bucket - final HostPasswordStore keychain = PasswordStoreFactory.get(); - - final OAuthTokens tokens = keychain.findOAuthTokens(hubSession.getHost()); - final Host bookmark = new VaultServiceImpl(vaultResource, storageProfileResource).getStorageBackend(ProtocolFactory.get(), - hubSession, configResource.apiConfigGet(), vaultDto.getId(), metadataPayload.storage(), tokens); - if(storageProfileWrapper.getProtocol() == Protocol.S3) { - // permanent: template upload into existing bucket from client (not backend) - templateUploadService.uploadTemplate(bookmark, metadataPayload, storageDto, hashedRootDirId); - } - else { - // non-permanent: pass STS tokens (restricted by inline policy) to hub backend and have bucket created from there - final TemporaryAccessTokens stsTokens = stsInlinePolicyService.getSTSTokensFromAccessTokenWithCreateBucketInlinePolicy( - tokens.getAccessToken(), - storageProfileWrapper.getStsRoleArnClient(), - vaultDto.getId().toString(), - storageProfileWrapper.getStsEndpoint(), - String.format("%s%s", storageProfileWrapper.getBucketPrefix(), vaultDto.getId()), - vaultModel.region(), - storageProfileWrapper.getBucketAcceleration() - ); - log.debug("Create STS bucket {} for vault {}", storageDto, vaultDto); - storageResource.apiStorageVaultIdPut(vaultDto.getId(), - storageDto.awsAccessKey(stsTokens.getAccessKeyId()) - .awsSecretKey(stsTokens.getSecretAccessKey()) - .sessionToken(stsTokens.getSessionToken())); - } - - // (3) upload JWE to hub - log.debug("Upload JWE {} for vault {}", uvfMetadataFile, vaultDto); - final UserDto userDto = users.apiUsersMeGet(false, false); - vaultResource.apiVaultsVaultIdAccessTokensPost(vaultDto.getId(), Collections.singletonMap(userDto.getId(), jwks.toOwnerAccessToken().encryptForUser(userKeys.ecdhKeyPair().getPublic()))); - } - catch(JOSEException | JsonProcessingException e) { - throw new SecurityFailure(e); - } - catch(IOException e) { - throw new AccessException(e); - } - } - - static class TemplateUploadService { - static TemplateUploadService disabled = new TemplateUploadService() { - @Override - void uploadTemplate(final Host bookmark, final UvfMetadataPayload metadataPayload, final CreateS3STSBucketDto storageDto, final String hashedRootDirId) { - // do nothing - } - }; - - void uploadTemplate(final Host bookmark, final UvfMetadataPayload metadataPayload, final CreateS3STSBucketDto storageDto, final String hashedRootDirId) throws BackgroundException { - final S3Session session = new S3Session(bookmark); - session.open(new DisabledProxyFinder(), new DisabledHostKeyCallback(), new DisabledLoginCallback(), new DisabledCancelCallback()); - session.login(new DisabledLoginCallback(), new DisabledCancelCallback()); - - // upload vault template - new HubUVFVault(session, new Path(metadataPayload.storage().getDefaultPath(), EnumSet.of(Path.Type.directory, Path.Type.vault))) - .create(session, metadataPayload.storage().getRegion(), storageDto.getVaultUvf(), hashedRootDirId, Base64.getUrlDecoder().decode(storageDto.getDirUvf())); - session.close(); - } - } - - static class STSInlinePolicyService { - static STSInlinePolicyService disabled = new STSInlinePolicyService() { - @Override - TemporaryAccessTokens getSTSTokensFromAccessTokenWithCreateBucketInlinePolicy(final String token, final String roleArn, final String roleSessionName, final String stsEndpoint, final String bucketName, final String region, final Boolean bucketAcceleration) { - return TemporaryAccessTokens.EMPTY; - } - }; - - TemporaryAccessTokens getSTSTokensFromAccessTokenWithCreateBucketInlinePolicy(final String token, final String roleArn, final String roleSessionName, final String stsEndpoint, final String bucketName, final String region, final Boolean bucketAcceleration) throws IOException { - log.debug("Get STS tokens from {} to pass to backend {} with role {} and session name {}", token, stsEndpoint, roleArn, roleSessionName); - - final AssumeRoleWithWebIdentityRequest request = new AssumeRoleWithWebIdentityRequest(); - - request.setWebIdentityToken(token); - - String inlinePolicy = IOUtils.toString(CreateVaultService.class.getResourceAsStream("/sts_create_bucket_inline_policy_template.json"), Charset.defaultCharset()).replace("{}", bucketName); - if((bucketAcceleration != null) && bucketAcceleration) { - inlinePolicy = inlinePolicy.replace("s3:PutEncryptionConfiguration\"", "s3:PutEncryptionConfiguration\", \"s3:GetAccelerateConfiguration\",\n \"s3:PutAccelerateConfiguration\""); - } - - request.setPolicy(inlinePolicy); - request.setRoleArn(roleArn); - request.setRoleSessionName(roleSessionName); - - AWSSecurityTokenServiceClientBuilder serviceBuild = AWSSecurityTokenServiceClientBuilder - .standard(); - // Exactly only one of Region or EndpointConfiguration may be set. - if(stsEndpoint != null) { - serviceBuild.withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(stsEndpoint, null)); - } - else { - serviceBuild.withRegion(region); - } - final AWSSecurityTokenService service = serviceBuild - .withCredentials(new AWSStaticCredentialsProvider(new AnonymousAWSCredentials())) - .build(); - - log.debug("Use request {}", request); - final AssumeRoleWithWebIdentityResult result = service.assumeRoleWithWebIdentity(request); - log.debug("Received assume role identity result {}", result); - return new TemporaryAccessTokens(result.getCredentials().getAccessKeyId(), - result.getCredentials().getSecretAccessKey(), - result.getCredentials().getSessionToken(), - result.getCredentials().getExpiration().getTime()); - } - } - - public static class CreateVaultModel { - - private final UUID vaultId; - private final String vaultName; - private final String vaultDescription; - private final String storageProfileId; - private final String accessKeyId; - private final String secretKey; - private final String bucketName; - private final String region; - private final boolean automaticAccessGrant; - private final int maxWotLevel; - - - public CreateVaultModel(final UUID vaultId, final String vaultName, final String vaultDescription, final String storageProfileId, - final String accessKeyId, final String secretKey, - final String bucketName, final String region, - final boolean automaticAccessGrant, final int maxWotLevel) { - this.vaultId = vaultId; - this.vaultName = vaultName; - this.vaultDescription = vaultDescription; - this.storageProfileId = storageProfileId; - this.accessKeyId = accessKeyId; - this.secretKey = secretKey; - this.bucketName = bucketName; - this.region = region; - this.automaticAccessGrant = automaticAccessGrant; - this.maxWotLevel = maxWotLevel; - } - - public UUID vaultId() { - return vaultId; - } - - public String vaultName() { - return vaultName; - } - - public String vaultDescription() { - return vaultDescription; - } - - public String storageProfileId() { - return storageProfileId; - } - - public String accessKeyId() { - return accessKeyId; - } - - public String secretKey() { - return secretKey; - } - - public String bucketName() { - return bucketName; - } - - public String region() { - return region; - } - - public boolean automaticAccessGrant() { - return automaticAccessGrant; - } - - public int maxWotLevel() { - return maxWotLevel; - } - } -} diff --git a/hub/src/main/java/cloud/katta/workflows/UserKeysServiceImpl.java b/hub/src/main/java/cloud/katta/workflows/UserKeysServiceImpl.java index da349b63..e2df5bc1 100644 --- a/hub/src/main/java/cloud/katta/workflows/UserKeysServiceImpl.java +++ b/hub/src/main/java/cloud/katta/workflows/UserKeysServiceImpl.java @@ -94,10 +94,9 @@ else if(validate(me)) { // TODO https://github.com/shift7-ch/katta-server/issues/27 // private key generated with P384KeyPair causes "Unexpected Error: Data provided to an operation does not meet requirements" in `UserKeys.recover`: `const privateKey = await crypto.subtle.importKey('pkcs8', decodedPrivateKey, UserKeys.KEY_DESIGNATION, false, UserKeys.KEY_USAGES);` final String accountKey = prompt.generateAccountKey(); - final String deviceName = prompt.displayAccountKeyAndAskDeviceName(hub, + final AccountKeyAndDeviceName input = prompt.displayAccountKeyAndAskDeviceName(hub, new AccountKeyAndDeviceName().withAccountKey(accountKey).withDeviceName(COMPUTER_NAME)); - - return this.uploadDeviceKeys(deviceName, + return this.uploadDeviceKeys(input.deviceName(), this.uploadUserKeys(me, prompt.generateUserKeys(), accountKey), deviceKeyPair); } } diff --git a/hub/src/main/java/cloud/katta/workflows/VaultService.java b/hub/src/main/java/cloud/katta/workflows/VaultService.java index 8966d66d..14c560f1 100644 --- a/hub/src/main/java/cloud/katta/workflows/VaultService.java +++ b/hub/src/main/java/cloud/katta/workflows/VaultService.java @@ -4,18 +4,15 @@ package cloud.katta.workflows; -import ch.cyberduck.core.Host; -import ch.cyberduck.core.OAuthTokens; -import ch.cyberduck.core.ProtocolFactory; +import ch.cyberduck.core.Session; import java.util.UUID; import cloud.katta.client.ApiException; -import cloud.katta.client.model.ConfigDto; import cloud.katta.crypto.UserKeys; import cloud.katta.crypto.uvf.UvfAccessTokenPayload; import cloud.katta.crypto.uvf.UvfMetadataPayload; -import cloud.katta.crypto.uvf.VaultMetadataJWEBackendDto; +import cloud.katta.model.StorageProfileDtoWrapper; import cloud.katta.protocols.hub.HubSession; import cloud.katta.workflows.exceptions.AccessException; import cloud.katta.workflows.exceptions.SecurityFailure; @@ -32,7 +29,7 @@ public interface VaultService { * @param userKeys EC key pair * @return Vault metadata */ - UvfMetadataPayload getVaultMetadataJWE(UUID vaultId, UserKeys userKeys) throws ApiException, SecurityFailure, AccessException; + UvfMetadataPayload getVaultMetadataJWE(UUID vaultId, UserKeys userKeys) throws ApiException, AccessException, SecurityFailure; /** * Get vault access token containing vault member key and recovery key (if owner) @@ -46,16 +43,21 @@ public interface VaultService { UvfAccessTokenPayload getVaultAccessTokenJWE(UUID vaultId, UserKeys userKeys) throws ApiException, AccessException, SecurityFailure; /** - * Prepares (virtual) bookmark for vault to access its configured storage backend. + * Get storage configuration for vault * - * @param protocols Registered protocol implementations to access backend storage - * @param hub Hub API Connection - * @param configDto Hub configuration - * @param vaultId Vault ID - * @param metadata Storage Backend configuration - * @return Configuration - * @throws AccessException Unsupported backend storage protocol - * @throws ApiException Server error accessing storage profile + * @param metadataPayload Vault metadata including storage configuration + * @return Storage profile */ - Host getStorageBackend(final ProtocolFactory protocols, final HubSession hub, final ConfigDto configDto, UUID vaultId, VaultMetadataJWEBackendDto metadata, final OAuthTokens tokens) throws AccessException, ApiException; + StorageProfileDtoWrapper getVaultStorageProfile(UvfMetadataPayload metadataPayload) throws ApiException, AccessException, SecurityFailure; + + /** + * Get storage session for vault + * + * @param session Hub Connection + * @param vaultId Vault ID + * @param metadataPayload Vault metadata including storage configuration + * @return Storage Session + * @throws AccessException Unsupported storage configuration found for vault + */ + Session getVaultStorageSession(HubSession session, UUID vaultId, UvfMetadataPayload metadataPayload) throws ApiException, AccessException; } diff --git a/hub/src/main/java/cloud/katta/workflows/VaultServiceImpl.java b/hub/src/main/java/cloud/katta/workflows/VaultServiceImpl.java index 26b85fd7..1c1daed4 100644 --- a/hub/src/main/java/cloud/katta/workflows/VaultServiceImpl.java +++ b/hub/src/main/java/cloud/katta/workflows/VaultServiceImpl.java @@ -4,12 +4,13 @@ package cloud.katta.workflows; -import ch.cyberduck.core.Credentials; +import ch.cyberduck.core.CredentialsConfigurator; import ch.cyberduck.core.Host; -import ch.cyberduck.core.OAuthTokens; -import ch.cyberduck.core.Profile; import ch.cyberduck.core.Protocol; import ch.cyberduck.core.ProtocolFactory; +import ch.cyberduck.core.Session; +import ch.cyberduck.core.exception.InteroperabilityException; +import ch.cyberduck.core.exception.LoginCanceledException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -21,16 +22,15 @@ import cloud.katta.client.ApiException; import cloud.katta.client.api.StorageProfileResourceApi; import cloud.katta.client.api.VaultResourceApi; -import cloud.katta.client.model.ConfigDto; import cloud.katta.client.model.VaultDto; import cloud.katta.crypto.UserKeys; import cloud.katta.crypto.uvf.UvfAccessTokenPayload; import cloud.katta.crypto.uvf.UvfMetadataPayload; import cloud.katta.crypto.uvf.VaultMetadataJWEBackendDto; import cloud.katta.model.StorageProfileDtoWrapper; +import cloud.katta.protocols.hub.HubAwareProfile; import cloud.katta.protocols.hub.HubSession; -import cloud.katta.protocols.hub.serializer.HubConfigDtoDeserializer; -import cloud.katta.protocols.hub.serializer.StorageProfileDtoWrapperDeserializer; +import cloud.katta.protocols.s3.S3AssumeRoleSession; import cloud.katta.workflows.exceptions.AccessException; import cloud.katta.workflows.exceptions.SecurityFailure; import com.fasterxml.jackson.core.JsonProcessingException; @@ -38,7 +38,6 @@ import com.nimbusds.jose.jwk.OctetSequenceKey; import static cloud.katta.crypto.uvf.UvfMetadataPayload.UniversalVaultFormatJWKS.memberKeyFromRawKey; -import static cloud.katta.protocols.s3.S3AssumeRoleProtocol.*; public class VaultServiceImpl implements VaultService { private static final Logger log = LogManager.getLogger(VaultServiceImpl.class); @@ -84,68 +83,33 @@ public UvfAccessTokenPayload getVaultAccessTokenJWE(final UUID vaultId, final Us } @Override - public Host getStorageBackend(final ProtocolFactory protocols, final HubSession hub, final ConfigDto configDto, final UUID vaultId, final VaultMetadataJWEBackendDto vaultMetadata, final OAuthTokens tokens) throws ApiException, AccessException { - if(null == protocols.forName(vaultMetadata.getProvider())) { - log.debug("Load missing profile {}", vaultMetadata.getProvider()); - final StorageProfileDtoWrapper storageProfile = StorageProfileDtoWrapper.coerce(storageProfileResourceApi - .apiStorageprofileProfileIdGet(UUID.fromString(vaultMetadata.getProvider()))); - log.debug("Read storage profile {}", storageProfile); - switch(storageProfile.getProtocol()) { - case S3: - case S3_STS: - final Profile profile = new HubAwareProfile(hub, protocols.forType(protocols.find(ProtocolFactory.BUNDLED_PROFILE_PREDICATE), Type.s3), - configDto, storageProfile); - log.debug("Register storage profile {}", profile); - protocols.register(profile); - break; - default: - throw new AccessException(String.format("Unsupported storage configuration %s", storageProfile.getProtocol().name())); - } - } - final String provider = vaultMetadata.getProvider(); - log.debug("Lookup provider {} from vault metadata", provider); - final Protocol protocol = protocols.forName(provider); - if((protocol.getOAuthTokenUrl() != null) && (!protocol.getOAuthTokenUrl().equals(configDto.getKeycloakTokenEndpoint()))) { - // this may happen if the storage profile ID is deployed to two different hubs - throw new AccessException(String.format("Expected keycloak endpoint %s, found %s.", configDto.getKeycloakTokenEndpoint(), protocol.getOAuthTokenUrl())); - } - final Host bookmark = new Host(protocol); - log.debug("Configure bookmark for vault {}", vaultMetadata); - bookmark.setNickname(vaultMetadata.getNickname()); - bookmark.setDefaultPath(vaultMetadata.getDefaultPath()); - final Credentials credentials = bookmark.getCredentials(); - credentials.setOauth(tokens); - if(vaultMetadata.getUsername() != null) { - credentials.setUsername(vaultMetadata.getUsername()); - } - if(vaultMetadata.getPassword() != null) { - credentials.setPassword(vaultMetadata.getPassword()); - } - if(protocol.getProperties().get(S3_ASSUMEROLE_ROLEARN) != null) { - bookmark.setProperty(OAUTH_TOKENEXCHANGE_VAULT, vaultId.toString()); - bookmark.setProperty(OAUTH_TOKENEXCHANGE_BASEPATH, this.vaultResource.getApiClient().getBasePath()); - } - // region as chosen by user upon vault creation (STS) or as retrieved from bucket (permanent) - bookmark.setRegion(vaultMetadata.getRegion()); - return bookmark; + public StorageProfileDtoWrapper getVaultStorageProfile(final UvfMetadataPayload metadataPayload) throws ApiException { + log.debug("Load profile {}", metadataPayload.storage().getProvider()); + return StorageProfileDtoWrapper.coerce(storageProfileResourceApi + .apiStorageprofileProfileIdGet(UUID.fromString(metadataPayload.storage().getProvider()))); } - private static final class HubAwareProfile extends Profile { - private final HubSession hub; - - public HubAwareProfile(final HubSession hub, final Protocol parent, final ConfigDto configDto, final StorageProfileDtoWrapper storageProfile) { - super(parent, new StorageProfileDtoWrapperDeserializer( - new HubConfigDtoDeserializer(configDto), storageProfile)); - this.hub = hub; - } - - @SuppressWarnings("unchecked") - @Override - public T getFeature(final Class type) { - if(type == HubSession.class) { - return (T) hub; - } - return super.getFeature(type); + @Override + public Session getVaultStorageSession(final HubSession session, final UUID vaultId, final UvfMetadataPayload vaultMetadata) throws ApiException, AccessException { + final StorageProfileDtoWrapper vaultStorageProfile = this.getVaultStorageProfile(vaultMetadata); + switch(vaultStorageProfile.getProtocol()) { + case S3: + case S3_STS: + final VaultMetadataJWEBackendDto vaultStorageMetadata = vaultMetadata.storage(); + try { + final S3AssumeRoleSession storage = new S3AssumeRoleSession(session, vaultId, new Host(new HubAwareProfile( + ProtocolFactory.get().forType(ProtocolFactory.get().find(ProtocolFactory.BUNDLED_PROFILE_PREDICATE), Protocol.Type.s3), session.getConfig(), vaultStorageProfile), + session.getFeature(CredentialsConfigurator.class).reload().configure(session.getHost()) + .setUsername(vaultStorageMetadata.getUsername()).setPassword(vaultStorageMetadata.getPassword())).withRegion(vaultStorageMetadata.getRegion())); + log.debug("Configured {} for vault {}", storage, vaultId); + return storage; + } + catch(LoginCanceledException e) { + throw new AccessException(e); + } + default: + log.warn("Unsupported storage configuration {} for vault {}", vaultStorageProfile.getProtocol(), vaultId); + throw new AccessException(new InteroperabilityException()); } } } diff --git a/hub/src/main/resources/sts_create_bucket_inline_policy_template.json b/hub/src/main/resources/sts_create_bucket_inline_policy_template.json deleted file mode 100644 index 72c96460..00000000 --- a/hub/src/main/resources/sts_create_bucket_inline_policy_template.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "s3:CreateBucket", - "s3:GetBucketPolicy", - "s3:PutBucketVersioning", - "s3:GetBucketVersioning", - "s3:GetEncryptionConfiguration", - "s3:PutEncryptionConfiguration" - ], - "Resource": "arn:aws:s3:::{}" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject" - ], - "Resource": [ - "arn:aws:s3:::{}/*.uvf", - "arn:aws:s3:::{}/*/" - ] - } - ] -} diff --git a/hub/src/test/java/cloud/katta/core/AbstractHubSynchronizeTest.java b/hub/src/test/java/cloud/katta/core/AbstractHubSynchronizeTest.java index 4d19858a..b0e1e89f 100644 --- a/hub/src/test/java/cloud/katta/core/AbstractHubSynchronizeTest.java +++ b/hub/src/test/java/cloud/katta/core/AbstractHubSynchronizeTest.java @@ -4,7 +4,17 @@ package cloud.katta.core; -import ch.cyberduck.core.*; +import ch.cyberduck.core.AlphanumericRandomStringService; +import ch.cyberduck.core.AttributedList; +import ch.cyberduck.core.DisabledConnectionCallback; +import ch.cyberduck.core.DisabledListProgressListener; +import ch.cyberduck.core.DisabledLoginCallback; +import ch.cyberduck.core.ListService; +import ch.cyberduck.core.OAuthTokens; +import ch.cyberduck.core.Path; +import ch.cyberduck.core.Session; +import ch.cyberduck.core.SimplePathPredicate; +import ch.cyberduck.core.UUIDRandomStringService; import ch.cyberduck.core.exception.AccessDeniedException; import ch.cyberduck.core.exception.BackgroundException; import ch.cyberduck.core.exception.NotfoundException; @@ -19,12 +29,11 @@ import ch.cyberduck.core.features.Vault; import ch.cyberduck.core.features.Write; import ch.cyberduck.core.io.StatusOutputStream; -import ch.cyberduck.core.proxy.DisabledProxyFinder; -import ch.cyberduck.core.s3.S3Session; import ch.cyberduck.core.transfer.Transfer; import ch.cyberduck.core.transfer.TransferItem; import ch.cyberduck.core.transfer.TransferStatus; -import ch.cyberduck.core.worker.DeleteWorker; +import ch.cyberduck.core.vault.VaultCredentials; +import ch.cyberduck.core.vault.VaultRegistry; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.RandomUtils; @@ -46,32 +55,27 @@ import java.util.EnumSet; import java.util.List; import java.util.UUID; -import java.util.stream.Collectors; import cloud.katta.client.ApiClient; import cloud.katta.client.ApiException; -import cloud.katta.client.api.ConfigResourceApi; import cloud.katta.client.api.StorageProfileResourceApi; import cloud.katta.client.api.UsersResourceApi; -import cloud.katta.client.api.VaultResourceApi; -import cloud.katta.client.model.ConfigDto; -import cloud.katta.client.model.Protocol; import cloud.katta.client.model.S3SERVERSIDEENCRYPTION; import cloud.katta.client.model.S3STORAGECLASSES; import cloud.katta.client.model.StorageProfileDto; import cloud.katta.client.model.StorageProfileS3Dto; import cloud.katta.client.model.StorageProfileS3STSDto; -import cloud.katta.crypto.UserKeys; +import cloud.katta.crypto.uvf.UvfMetadataPayload; +import cloud.katta.crypto.uvf.VaultMetadataJWEAutomaticAccessGrantDto; import cloud.katta.crypto.uvf.VaultMetadataJWEBackendDto; import cloud.katta.model.StorageProfileDtoWrapper; import cloud.katta.protocols.hub.HubSession; +import cloud.katta.protocols.hub.HubStorageLocationService; +import cloud.katta.protocols.hub.HubUVFVault; import cloud.katta.protocols.hub.HubVaultRegistry; import cloud.katta.testsetup.AbstractHubTest; import cloud.katta.testsetup.HubTestConfig; import cloud.katta.testsetup.MethodIgnorableSource; -import cloud.katta.workflows.CreateVaultService; -import cloud.katta.workflows.DeviceKeysServiceImpl; -import cloud.katta.workflows.UserKeysServiceImpl; import cloud.katta.workflows.VaultServiceImpl; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; @@ -227,56 +231,38 @@ public void test03AddVault(final HubTestConfig config) throws Exception { .filter(p -> p.getId().toString().equals(config.vault.storageProfileId.toLowerCase())).findFirst().get(); log.info("Creating vault in {}", hubSession); - final UUID vaultId = UUID.randomUUID(); - - - if(storageProfileWrapper.getProtocol() == Protocol.S3) { - // empty bucket - final HostPasswordStore keychain = PasswordStoreFactory.get(); - - final OAuthTokens tokens = keychain.findOAuthTokens(hubSession.getHost()); - final Host bookmark = new VaultServiceImpl(new VaultResourceApi(hubSession.getClient()), new StorageProfileResourceApi(hubSession.getClient())) - .getStorageBackend( - ProtocolFactory.get(), - hubSession, - new ConfigResourceApi(hubSession.getClient()).apiConfigGet(), vaultId, new VaultMetadataJWEBackendDto() - .provider(storageProfileWrapper.getId().toString()) - .defaultPath(config.vault.bucketName) - .nickname(config.vault.bucketName) - .username(config.vault.username) - .password(config.vault.password), tokens); - final S3Session session = new S3Session(bookmark); - session.open(new DisabledProxyFinder(), new DisabledHostKeyCallback(), new DisabledLoginCallback(), new DisabledCancelCallback()); - session.login(new DisabledLoginCallback(), new DisabledCancelCallback()); - new DeleteWorker(new DisabledLoginCallback(), - session.getFeature(ListService.class).list(new Path("/" + config.vault.bucketName, EnumSet.of(AbstractPath.Type.directory)), new DisabledListProgressListener()).toStream().filter(f -> session.getFeature(Delete.class).isSupported(f)).collect(Collectors.toList()), - new DisabledListProgressListener()).run(session); - session.close(); - } + final UUID vaultId = UUID.fromString(new UUIDRandomStringService().random()); - final UserKeys userKeys = new UserKeysServiceImpl(hubSession).getUserKeys(hubSession.getHost(), hubSession.getMe(), - new DeviceKeysServiceImpl().getDeviceKeys(hubSession.getHost())); - new CreateVaultService(hubSession).createVault(userKeys, storageProfileWrapper, new CreateVaultService.CreateVaultModel( - vaultId, "vault", null, - config.vault.storageProfileId, config.vault.username, config.vault.password, config.vault.bucketName, config.vault.region, true, 3)); + final Path bucket = new Path(null == storageProfileWrapper.getBucketPrefix() ? "katta-test-" + vaultId : storageProfileWrapper.getBucketPrefix() + vaultId, + EnumSet.of(Path.Type.volume, Path.Type.directory)); + final HubStorageLocationService.StorageLocation location = new HubStorageLocationService.StorageLocation(storageProfileWrapper.getId().toString(), storageProfileWrapper.getRegion(), + storageProfileWrapper.getName()); + final UvfMetadataPayload vaultMetadata = UvfMetadataPayload.create() + .withStorage(new VaultMetadataJWEBackendDto() + .username(config.vault.username) + .password(config.vault.password) + .provider(location.getProfile()) + .defaultPath(bucket.getAbsolute()) + .region(location.getRegion()) + .nickname(null != bucket.attributes().getDisplayname() ? bucket.attributes().getDisplayname() : "Vault")) + .withAutomaticAccessGrant(new VaultMetadataJWEAutomaticAccessGrantDto() + .enabled(true) + .maxWotDepth(null)); + final HubUVFVault cryptomator = new HubUVFVault(new VaultServiceImpl(hubSession).getVaultStorageSession(hubSession, vaultId, vaultMetadata), + vaultId, vaultMetadata, new DisabledLoginCallback()); + cryptomator.create(hubSession, location.getIdentifier(), new VaultCredentials(StringUtils.EMPTY)); final AttributedList vaults = hubSession.getFeature(ListService.class).list(Home.root(), new DisabledListProgressListener()); assertFalse(vaults.isEmpty()); - final Path bucket = new Path(storageProfileWrapper.getProtocol() == Protocol.S3_STS ? storageProfileWrapper.getBucketPrefix() + vaultId : config.vault.bucketName, - EnumSet.of(Path.Type.volume, Path.Type.directory)); - final HubVaultRegistry vaultRegistry = hubSession.getRegistry(); + final VaultRegistry vaultRegistry = hubSession.getRegistry(); + assertInstanceOf(HubVaultRegistry.class, vaultRegistry); { assertNotNull(vaults.find(new SimplePathPredicate(bucket))); - assertTrue(hubSession.getFeature(Find.class).find(bucket)); assertEquals(config.vault.region, hubSession.getFeature(AttributesFinder.class).find(bucket).getRegion()); assertNotSame(Vault.DISABLED, vaultRegistry.find(hubSession, bucket)); - - // listing decrypted file names - assertFalse(vaultRegistry.isEmpty()); - assertNotSame(Vault.DISABLED, vaultRegistry.find(hubSession, bucket)); } final Path vault = vaults.find(new SimplePathPredicate(bucket)); @@ -352,9 +338,6 @@ public void test04SetupCode(final HubTestConfig config) throws Exception { assertEquals(StringUtils.EMPTY, hubSession.getHost().getCredentials().getPassword()); final ListService feature = hubSession.getFeature(ListService.class); final AttributedList vaults = feature.list(Home.root(), new DisabledListProgressListener()); - final ConfigDto configDto = new ConfigResourceApi(hubSession.getClient()).apiConfigGet(); - final int expectedNumberOfVaults = configDto.getKeycloakTokenEndpoint().contains("localhost") ? 2 : 4; - assertEquals(expectedNumberOfVaults, vaults.size()); assertEquals(vaults, feature.list(Home.root(), new DisabledListProgressListener())); for(final Path vault : vaults) { assertTrue(hubSession.getFeature(Find.class).find(vault)); diff --git a/hub/src/test/java/cloud/katta/core/util/MockableDeviceSetupCallback.java b/hub/src/test/java/cloud/katta/core/util/MockableDeviceSetupCallback.java deleted file mode 100644 index 6aa506d8..00000000 --- a/hub/src/test/java/cloud/katta/core/util/MockableDeviceSetupCallback.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2025 shift7 GmbH. All rights reserved. - */ - -package cloud.katta.core.util; - -import ch.cyberduck.core.Host; - -import cloud.katta.core.DeviceSetupCallback; -import cloud.katta.model.AccountKeyAndDeviceName; -import cloud.katta.workflows.exceptions.AccessException; - -public class MockableDeviceSetupCallback implements DeviceSetupCallback { - public static void setProxy(final DeviceSetupCallback proxy) { - MockableDeviceSetupCallback.proxy = proxy; - } - - private static DeviceSetupCallback proxy = null; - - @Override - public String displayAccountKeyAndAskDeviceName(final Host bookmark, final AccountKeyAndDeviceName accountKeyAndDeviceName) throws AccessException { - return proxy.displayAccountKeyAndAskDeviceName(bookmark, accountKeyAndDeviceName); - } - - @Override - public AccountKeyAndDeviceName askForAccountKeyAndDeviceName(final Host bookmark, final String initialDeviceName) throws AccessException { - return proxy.askForAccountKeyAndDeviceName(bookmark, initialDeviceName); - } - - @Override - public String generateAccountKey() { - return proxy.generateAccountKey(); - } -} diff --git a/hub/src/test/java/cloud/katta/protocols/hub/HubGrantAccessSchedulerServiceTest.java b/hub/src/test/java/cloud/katta/protocols/hub/HubGrantAccessSchedulerServiceTest.java deleted file mode 100644 index 71bd7590..00000000 --- a/hub/src/test/java/cloud/katta/protocols/hub/HubGrantAccessSchedulerServiceTest.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright (c) 2025 shift7 GmbH. All rights reserved. - */ - -package cloud.katta.protocols.hub; - -import ch.cyberduck.core.Credentials; -import ch.cyberduck.core.Host; -import ch.cyberduck.core.HostPasswordStore; -import ch.cyberduck.core.exception.BackgroundException; - -import org.joda.time.DateTime; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.Arrays; -import java.util.Base64; -import java.util.UUID; - -import cloud.katta.client.ApiException; -import cloud.katta.client.HubApiClient; -import cloud.katta.client.api.DeviceResourceApi; -import cloud.katta.client.api.UsersResourceApi; -import cloud.katta.client.api.VaultResourceApi; -import cloud.katta.client.model.DeviceDto; -import cloud.katta.client.model.Role; -import cloud.katta.client.model.Type1; -import cloud.katta.client.model.UserDto; -import cloud.katta.client.model.VaultDto; -import cloud.katta.crypto.DeviceKeys; -import cloud.katta.crypto.UserKeys; -import cloud.katta.protocols.hub.HubGrantAccessSchedulerService; -import cloud.katta.protocols.hub.HubSession; -import cloud.katta.workflows.GrantAccessService; -import cloud.katta.workflows.exceptions.AccessException; -import cloud.katta.workflows.exceptions.SecurityFailure; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.nimbusds.jose.JOSEException; - -import static cloud.katta.crypto.KeyHelper.encodePrivateKey; -import static cloud.katta.crypto.KeyHelper.encodePublicKey; -import static cloud.katta.workflows.DeviceKeysServiceImpl.KEYCHAIN_PRIVATE_DEVICE_KEY_ACCOUNT_NAME; -import static cloud.katta.workflows.DeviceKeysServiceImpl.KEYCHAIN_PUBLIC_DEVICE_KEY_ACCOUNT_NAME; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.times; - -class HubGrantAccessSchedulerServiceTest { - - @Test - public void testOperate() throws BackgroundException, ApiException, JOSEException, JsonProcessingException, AccessException, SecurityFailure { - final HostPasswordStore keychain = Mockito.mock(HostPasswordStore.class); - final HubSession hubSession = Mockito.mock(HubSession.class); - final Host hub = Mockito.mock(Host.class); - final VaultResourceApi vaults = Mockito.mock(VaultResourceApi.class); - final UsersResourceApi users = Mockito.mock(UsersResourceApi.class); - final DeviceResourceApi devices = Mockito.mock(DeviceResourceApi.class); - final GrantAccessService grants = Mockito.mock(GrantAccessService.class); - - final UserKeys userKeys = UserKeys.create(); - final DeviceKeys deviceKeys = DeviceKeys.create(); - - final HubApiClient apiClient = Mockito.mock(HubApiClient.class); - - Mockito.when(hubSession.getHost()).thenReturn(hub); - - Mockito.when(hubSession.getClient()).thenReturn(apiClient); - Mockito.when(keychain.getPassword(eq(KEYCHAIN_PUBLIC_DEVICE_KEY_ACCOUNT_NAME), eq("Fritzl@Unterwittelsbach"))).thenReturn(encodePublicKey(deviceKeys.getEcKeyPair().getPublic())); - Mockito.when(keychain.getPassword(eq(KEYCHAIN_PRIVATE_DEVICE_KEY_ACCOUNT_NAME), eq("Fritzl@Unterwittelsbach"))).thenReturn(encodePrivateKey(deviceKeys.getEcKeyPair().getPrivate())); - Mockito.when(hubSession.getMe()).thenReturn(new UserDto() - .ecdhPublicKey(encodePublicKey(userKeys.ecdhKeyPair().getPublic())) - .ecdsaPublicKey(encodePublicKey(userKeys.ecdsaKeyPair().getPublic())) - ); - Mockito.when(hub.getCredentials()).thenReturn(new Credentials().setUsername("Fritzl")); - Mockito.when(hub.getHostname()).thenReturn("Unterwittelsbach"); - Mockito.when(devices.apiDevicesDeviceIdGet(any())).thenReturn(new DeviceDto() - .name("Franzl") - .publicKey(Base64.getEncoder().encodeToString(deviceKeys.getEcKeyPair().getPublic().getEncoded())) - .userPrivateKey(userKeys.encryptForDevice(deviceKeys.getEcKeyPair().getPublic())) - .type(Type1.DESKTOP) - .creationTime(new DateTime())); - final UUID vaultId = UUID.randomUUID(); - Mockito.when(vaults.apiVaultsAccessibleGet(Role.OWNER)).thenReturn(Arrays.asList( - new VaultDto().archived(true), new VaultDto().archived(false).id(vaultId), new VaultDto().archived(null))); - - final HubGrantAccessSchedulerService service = new HubGrantAccessSchedulerService(hubSession, keychain, vaults, users, devices, grants); - service.operate(null); - - Mockito.verify(grants, times(1)).grantAccessToUsersRequiringAccessGrant(eq(vaultId), any()); - } -} diff --git a/hub/src/test/java/cloud/katta/testsetup/AbstractHubTest.java b/hub/src/test/java/cloud/katta/testsetup/AbstractHubTest.java index d9bc2e34..3f192c86 100644 --- a/hub/src/test/java/cloud/katta/testsetup/AbstractHubTest.java +++ b/hub/src/test/java/cloud/katta/testsetup/AbstractHubTest.java @@ -9,12 +9,12 @@ import ch.cyberduck.core.preferences.Preferences; import ch.cyberduck.core.preferences.PreferencesFactory; import ch.cyberduck.core.profiles.LocalProfilesFinder; +import ch.cyberduck.core.serviceloader.AnnotationAutoServiceLoader; import ch.cyberduck.core.ssl.DefaultX509KeyManager; import ch.cyberduck.core.ssl.DefaultX509TrustManager; import ch.cyberduck.core.vault.VaultRegistryFactory; import ch.cyberduck.test.VaultTest; -import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Named; import org.junit.jupiter.params.provider.Arguments; @@ -28,13 +28,10 @@ import java.util.function.Function; import cloud.katta.core.DeviceSetupCallback; -import cloud.katta.core.util.MockableDeviceSetupCallback; import cloud.katta.model.AccountKeyAndDeviceName; -import cloud.katta.protocols.hub.HubProtocol; import cloud.katta.protocols.hub.HubSession; import cloud.katta.protocols.hub.HubUVFVault; import cloud.katta.protocols.hub.HubVaultRegistry; -import cloud.katta.protocols.s3.S3AssumeRoleProtocol; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -48,13 +45,13 @@ public abstract class AbstractHubTest extends VaultTest { } private static final HubTestConfig.VaultSpec minioSTSVaultConfig = new HubTestConfig.VaultSpec("MinIO STS", "732D43FA-3716-46C4-B931-66EA5405EF1C", - null, null, null, "eu-west-1"); + null, null, "eu-central-1"); private static final HubTestConfig.VaultSpec minioStaticVaultConfig = new HubTestConfig.VaultSpec("MinIO static", "71B910E0-2ECC-46DE-A871-8DB28549677E", - "handmade", "minioadmin", "minioadmin", "us-east-1"); + "minioadmin", "minioadmin", "us-east-1"); private static final HubTestConfig.VaultSpec awsSTSVaultConfig = new HubTestConfig.VaultSpec("AWS STS", "844BD517-96D4-4787-BCFA-238E103149F6", - null, null, null, "eu-west-1"); + null, null, "eu-west-1"); private static final HubTestConfig.VaultSpec awsStaticVaultConfig = new HubTestConfig.VaultSpec("AWS static", "72736C19-283C-49D3-80A5-AB74B5202543", - "handmade2", PROPERTIES.get("handmade2.s3.amazonaws.com.username"), PROPERTIES.get("handmade2.s3.amazonaws.com.password"), "eu-north-1" + PROPERTIES.get("handmade2.s3.amazonaws.com.username"), PROPERTIES.get("handmade2.s3.amazonaws.com.password"), "us-east-1" ); /** @@ -73,7 +70,7 @@ public abstract class AbstractHubTest extends VaultTest { } private static final Function argumentUnattendedLocalOnly = vs -> Arguments.of(Named.of( - String.format("%s %s (Bucket %s)", vs.storageProfileName, LOCAL.hubURL, vs.bucketName), + String.format("%s %s", vs.storageProfileName, LOCAL.hubURL), new HubTestConfig(LOCAL, vs))); @@ -88,7 +85,7 @@ public abstract class AbstractHubTest extends VaultTest { .withAdminConfig(new HubTestConfig.Setup.UserConfig("admin", "admin", staticSetupCode())) .withUserConfig(new HubTestConfig.Setup.UserConfig("alice", "asd", staticSetupCode())); private static final Function argumentAttendedLocalOnly = vs -> Arguments.of(Named.of( - String.format("%s %s (Bucket %s)", vs.storageProfileName, LOCAL_ATTENDED.hubURL, vs.bucketName), + String.format("%s %s", vs.storageProfileName, LOCAL_ATTENDED.hubURL), new HubTestConfig(LOCAL_ATTENDED, vs))); /** @@ -124,7 +121,7 @@ public abstract class AbstractHubTest extends VaultTest { } private static final Function argumentUnattendedHybrid = vs -> Arguments.of(Named.of( - String.format("%s %s (Bucket %s)", vs.storageProfileName, HYBRID.hubURL, vs.bucketName), + String.format("%s %s", vs.storageProfileName, HYBRID.hubURL), new HubTestConfig(HYBRID, vs))); @@ -149,7 +146,6 @@ protected void configureLogging(final String level) { preferences.setProperty("factory.vault.class", HubUVFVault.class.getName()); preferences.setProperty("factory.supportdirectoryfinder.class", ch.cyberduck.core.preferences.TemporarySupportDirectoryFinder.class.getName()); preferences.setProperty("factory.passwordstore.class", UnsecureHostPasswordStore.class.getName()); - preferences.setProperty("factory.devicesetupcallback.class", MockableDeviceSetupCallback.class.getName()); preferences.setProperty("factory.vaultregistry.class", HubVaultRegistry.class.getName()); preferences.setProperty("oauth.handler.scheme", "katta"); @@ -168,17 +164,10 @@ private static String staticSetupCode() { protected static HubSession setupConnection(final HubTestConfig.Setup setup) throws Exception { final ProtocolFactory factory = ProtocolFactory.get(); - // ProtocolFactory.get() is static, the profiles contains OAuth token URL, leads to invalid grant exceptions when this changes during class loading lifetime (e.g. if the same storage profile ID is deployed to the LOCAL and the HYBRID hub). - for(final Protocol protocol : ProtocolFactory.get().find()) { - if(protocol instanceof Profile) { - factory.unregister((Profile) protocol); - } - } // Register parent protocol definitions - factory.register( - new HubProtocol(), - new S3AssumeRoleProtocol("PasswordGrant") - ); + for(Protocol p : new AnnotationAutoServiceLoader().load(Protocol.class)) { + factory.register(p); + } // Load bundled profiles factory.load(new LocalProfilesFinder(factory, new Local(AbstractHubTest.class.getResource("/").toURI().getPath()))); assertNotNull(factory.forName("hub")); @@ -186,29 +175,42 @@ protected static HubSession setupConnection(final HubTestConfig.Setup setup) thr assertTrue(factory.forName("s3").isEnabled()); assertTrue(factory.forType(Protocol.Type.s3).isEnabled()); - final DeviceSetupCallback proxy = deviceSetupCallback(setup); - MockableDeviceSetupCallback.setProxy(proxy); - final Host hub = new HostParser(factory).get(setup.hubURL).withCredentials(new Credentials(setup.userConfig.username, setup.userConfig.password)); final HubSession session = (HubSession) SessionFactory.create(hub, new DefaultX509TrustManager(), new DefaultX509KeyManager()) .withRegistry(VaultRegistryFactory.get(new DisabledPasswordCallback())); - final LoginConnectionService login = new LoginConnectionService(new DisabledLoginCallback(), new DisabledHostKeyCallback(), + final LoginConnectionService login = new LoginConnectionService(loginCallback(setup), new DisabledHostKeyCallback(), PasswordStoreFactory.get(), new DisabledProgressListener()); login.check(session, new DisabledCancelCallback()); return session; } - protected static @NotNull DeviceSetupCallback deviceSetupCallback(HubTestConfig.Setup setup) { - final DeviceSetupCallback proxy = new DeviceSetupCallback() { + protected static LoginCallback loginCallback(HubTestConfig.Setup setup) { + return new DisabledLoginCallback() { + @SuppressWarnings("unchecked") + @Override + public T getFeature(final Class type) { + if(DeviceSetupCallback.class == type) { + return (T) deviceSetupCallback(setup); + } + return null; + } + }; + } + + protected static DeviceSetupCallback deviceSetupCallback(HubTestConfig.Setup setup) { + return new DeviceSetupCallback() { @Override - public String displayAccountKeyAndAskDeviceName(final Host bookmark, final AccountKeyAndDeviceName accountKeyAndDeviceName) { - return "firstLoginMockSetup"; + public AccountKeyAndDeviceName displayAccountKeyAndAskDeviceName(final Host bookmark, final AccountKeyAndDeviceName accountKeyAndDeviceName) { + return new AccountKeyAndDeviceName().withAccountKey(setup.userConfig.setupCode).withDeviceName( + String.format("%s %s", accountKeyAndDeviceName.deviceName(), DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL) + .format(ZonedDateTime.now(ZoneId.of("Europe/Zurich"))))); } @Override public AccountKeyAndDeviceName askForAccountKeyAndDeviceName(final Host bookmark, final String initialDeviceName) { - return new AccountKeyAndDeviceName().withAccountKey(setup.userConfig.setupCode).withDeviceName(String.format("firstLoginMockSetup %s", DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL) - .format(ZonedDateTime.now(ZoneId.of("Europe/Zurich"))))); + return new AccountKeyAndDeviceName().withAccountKey(setup.userConfig.setupCode).withDeviceName( + String.format("%s %s", initialDeviceName, DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL) + .format(ZonedDateTime.now(ZoneId.of("Europe/Zurich"))))); } @Override @@ -216,7 +218,6 @@ public String generateAccountKey() { return staticSetupCode(); } }; - return proxy; } } diff --git a/hub/src/test/java/cloud/katta/testsetup/HubTestConfig.java b/hub/src/test/java/cloud/katta/testsetup/HubTestConfig.java index e12cd6bd..b1fb6df2 100644 --- a/hub/src/test/java/cloud/katta/testsetup/HubTestConfig.java +++ b/hub/src/test/java/cloud/katta/testsetup/HubTestConfig.java @@ -105,15 +105,13 @@ public String toString() { public static class VaultSpec { public final String storageProfileName; public final String storageProfileId; - public final String bucketName; public final String username; public final String password; public final String region; - public VaultSpec(final String storageProfileName, final String storageProfileId, final String bucketName, final String username, final String password, final String region) { + public VaultSpec(final String storageProfileName, final String storageProfileId, final String username, final String password, final String region) { this.storageProfileName = storageProfileName; this.storageProfileId = storageProfileId; - this.bucketName = bucketName; this.username = username; this.password = password; this.region = region; diff --git a/hub/src/test/java/cloud/katta/workflows/AbstractHubWorkflowTest.java b/hub/src/test/java/cloud/katta/workflows/AbstractHubWorkflowTest.java index c7900a01..03d6991f 100644 --- a/hub/src/test/java/cloud/katta/workflows/AbstractHubWorkflowTest.java +++ b/hub/src/test/java/cloud/katta/workflows/AbstractHubWorkflowTest.java @@ -4,11 +4,18 @@ package cloud.katta.workflows; +import ch.cyberduck.core.DisabledLoginCallback; +import ch.cyberduck.core.Path; +import ch.cyberduck.core.UUIDRandomStringService; +import ch.cyberduck.core.vault.VaultCredentials; + +import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.junit.jupiter.params.ParameterizedTest; import java.io.IOException; +import java.util.EnumSet; import java.util.List; import java.util.UUID; @@ -23,9 +30,14 @@ import cloud.katta.client.model.UserDto; import cloud.katta.client.model.VaultDto; import cloud.katta.crypto.UserKeys; +import cloud.katta.crypto.uvf.UvfMetadataPayload; +import cloud.katta.crypto.uvf.VaultMetadataJWEAutomaticAccessGrantDto; +import cloud.katta.crypto.uvf.VaultMetadataJWEBackendDto; import cloud.katta.model.SetupCodeJWE; import cloud.katta.model.StorageProfileDtoWrapper; import cloud.katta.protocols.hub.HubSession; +import cloud.katta.protocols.hub.HubStorageLocationService; +import cloud.katta.protocols.hub.HubUVFVault; import cloud.katta.testsetup.AbstractHubTest; import cloud.katta.testsetup.HubTestConfig; import cloud.katta.testsetup.MethodIgnorableSource; @@ -51,18 +63,26 @@ public void testHubWorkflow(final HubTestConfig config) throws Exception { .map(StorageProfileDtoWrapper::coerce) .filter(p -> p.getId().toString().equals(config.vault.storageProfileId.toLowerCase())).findFirst().get(); - final UUID vaultId = UUID.randomUUID(); - final boolean automaticAccessGrant = true; - // upload template (STS: create bucket first, static: existing bucket) - // TODO test with multiple wot levels? + final UUID vaultId = UUID.fromString(new UUIDRandomStringService().random()); + final Path bucket = new Path(null == storageProfileWrapper.getBucketPrefix() ? "katta-test-" + vaultId : storageProfileWrapper.getBucketPrefix() + vaultId, + EnumSet.of(Path.Type.volume, Path.Type.directory)); + final HubStorageLocationService.StorageLocation location = new HubStorageLocationService.StorageLocation(storageProfileWrapper.getId().toString(), storageProfileWrapper.getRegion(), + storageProfileWrapper.getName()); + final UvfMetadataPayload vaultMetadata = UvfMetadataPayload.create() + .withStorage(new VaultMetadataJWEBackendDto() + .username(config.vault.username) + .password(config.vault.password) + .provider(location.getProfile()) + .defaultPath(bucket.getAbsolute()) + .region(location.getRegion()) + .nickname(null != bucket.attributes().getDisplayname() ? bucket.attributes().getDisplayname() : "Vault")) + .withAutomaticAccessGrant(new VaultMetadataJWEAutomaticAccessGrantDto() + .enabled(true) + .maxWotDepth(3)); + final HubUVFVault cryptomator = new HubUVFVault(new VaultServiceImpl(hubSession).getVaultStorageSession(hubSession, vaultId, vaultMetadata), + vaultId, vaultMetadata, new DisabledLoginCallback()); + cryptomator.create(hubSession, location.getIdentifier(), new VaultCredentials(StringUtils.EMPTY)); - final UserKeys userKeys = new UserKeysServiceImpl(hubSession).getUserKeys(hubSession.getHost(), hubSession.getMe(), - new DeviceKeysServiceImpl().getDeviceKeys(hubSession.getHost())); - new CreateVaultService(hubSession).createVault(userKeys, storageProfileWrapper, - new CreateVaultService.CreateVaultModel(vaultId, - "vault", null, - config.vault.storageProfileId, config.vault.username, config.vault.password, config.vault.bucketName, - config.vault.region, automaticAccessGrant, 3)); checkNumberOfVaults(hubSession, config, vaultId, 0, 0, 1, 0, 0); log.info("S02 {} alice shares vault with admin as owner", setup); @@ -96,6 +116,8 @@ public void testHubWorkflow(final HubTestConfig config) throws Exception { new DeviceKeysServiceImpl().getDeviceKeys(hubSession.getHost())), admin); log.info("S04 {} alice grants access to admin", setup); + final UserKeys userKeys = new UserKeysServiceImpl(hubSession).getUserKeys(hubSession.getHost(), hubSession.getMe(), + new DeviceKeysServiceImpl().getDeviceKeys(hubSession.getHost())); new GrantAccessServiceImpl(hubSession).grantAccessToUsersRequiringAccessGrant(vaultId, userKeys); checkNumberOfVaults(hubSession, config, vaultId, 1, 0, 1, 0, 0); } diff --git a/hub/src/test/java/cloud/katta/workflows/CachingUserKeysServiceTest.java b/hub/src/test/java/cloud/katta/workflows/CachingUserKeysServiceTest.java deleted file mode 100644 index bba88d19..00000000 --- a/hub/src/test/java/cloud/katta/workflows/CachingUserKeysServiceTest.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2025 shift7 GmbH. All rights reserved. - */ - -package cloud.katta.workflows; - -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import cloud.katta.client.ApiException; -import cloud.katta.crypto.UserKeys; -import cloud.katta.workflows.exceptions.AccessException; -import cloud.katta.workflows.exceptions.SecurityFailure; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.times; - -class CachingUserKeysServiceTest { - - @Test - void testGetUserKeys() throws AccessException, SecurityFailure, ApiException { - final UserKeysService proxyMock = Mockito.mock(UserKeysService.class); - final UserKeys userKeys = UserKeys.create(); - Mockito.when(proxyMock.getUserKeys(any(), any(), any())).thenReturn(userKeys); - final CachingUserKeysService service = new CachingUserKeysService(proxyMock); - assertEquals(userKeys, service.getUserKeys(null, null, null)); - Mockito.verify(proxyMock, times(1)).getUserKeys(any(), any(), any()); - assertEquals(userKeys, service.getUserKeys(null, null, null)); - Mockito.verify(proxyMock, times(1)).getUserKeys(any(), any(), any()); - } -} diff --git a/hub/src/test/java/cloud/katta/workflows/CachingWoTServiceTest.java b/hub/src/test/java/cloud/katta/workflows/CachingWoTServiceTest.java deleted file mode 100644 index 34d522f6..00000000 --- a/hub/src/test/java/cloud/katta/workflows/CachingWoTServiceTest.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2025 shift7 GmbH. All rights reserved. - */ - -package cloud.katta.workflows; - -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.text.ParseException; -import java.util.HashMap; - -import cloud.katta.client.ApiException; -import cloud.katta.client.model.TrustedUserDto; -import cloud.katta.workflows.exceptions.AccessException; -import cloud.katta.workflows.exceptions.SecurityFailure; -import com.nimbusds.jose.JOSEException; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.times; - -class CachingWoTServiceTest { - - @Test - void testGetTrustLevelsPerUserId() throws AccessException, SecurityFailure, ApiException { - final WoTService proxyMock = Mockito.mock(WoTService.class); - final HashMap trustLevels = new HashMap() {{ - put("alkdajf", 5); - put("lakdjfa", 42); - }}; - Mockito.when(proxyMock.getTrustLevelsPerUserId(any())).thenReturn(trustLevels); - final CachingWoTService service = new CachingWoTService(proxyMock); - assertEquals(trustLevels, service.getTrustLevelsPerUserId(null)); - Mockito.verify(proxyMock, times(1)).getTrustLevelsPerUserId(any()); - assertEquals(trustLevels, service.getTrustLevelsPerUserId(null)); - Mockito.verify(proxyMock, times(1)).getTrustLevelsPerUserId(any()); - } - - @Test - void testVerify() throws AccessException, SecurityFailure, ApiException { - final WoTService proxyMock = Mockito.mock(WoTService.class); - final CachingWoTService service = new CachingWoTService(proxyMock); - service.verify(null, null, null); - Mockito.verify(proxyMock, times(1)).verify(any(), any(), any()); - service.verify(null, null, null); - Mockito.verify(proxyMock, times(2)).verify(any(), any(), any()); - } - - @Test - void testSign() throws AccessException, SecurityFailure, ParseException, JOSEException, ApiException { - final WoTService proxyMock = Mockito.mock(WoTService.class); - final TrustedUserDto trustedUser = new TrustedUserDto(); - Mockito.when(proxyMock.sign(any(), any())).thenReturn(trustedUser); - final CachingWoTService service = new CachingWoTService(proxyMock); - assertEquals(trustedUser, service.sign(null, null)); - Mockito.verify(proxyMock, times(1)).sign(any(), any()); - assertEquals(trustedUser, service.sign(null, null)); - Mockito.verify(proxyMock, times(2)).sign(any(), any()); - } -} diff --git a/hub/src/test/java/cloud/katta/workflows/CreateVaultServiceTest.java b/hub/src/test/java/cloud/katta/workflows/CreateVaultServiceTest.java deleted file mode 100644 index b592a8e3..00000000 --- a/hub/src/test/java/cloud/katta/workflows/CreateVaultServiceTest.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright (c) 2025 shift7 GmbH. All rights reserved. - */ - -package cloud.katta.workflows; - -import ch.cyberduck.core.Credentials; -import ch.cyberduck.core.Host; -import ch.cyberduck.core.Local; -import ch.cyberduck.core.ProtocolFactory; -import ch.cyberduck.core.exception.BackgroundException; -import ch.cyberduck.core.profiles.LocalProfilesFinder; - -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.io.IOException; -import java.net.URISyntaxException; -import java.util.UUID; - -import cloud.katta.client.ApiException; -import cloud.katta.client.HubApiClient; -import cloud.katta.client.api.ConfigResourceApi; -import cloud.katta.client.api.StorageProfileResourceApi; -import cloud.katta.client.api.StorageResourceApi; -import cloud.katta.client.api.UsersResourceApi; -import cloud.katta.client.api.VaultResourceApi; -import cloud.katta.client.model.ConfigDto; -import cloud.katta.client.model.Protocol; -import cloud.katta.client.model.StorageProfileDto; -import cloud.katta.client.model.StorageProfileS3STSDto; -import cloud.katta.client.model.UserDto; -import cloud.katta.crypto.UserKeys; -import cloud.katta.model.StorageProfileDtoWrapper; -import cloud.katta.protocols.hub.HubProtocol; -import cloud.katta.protocols.hub.HubSession; -import cloud.katta.protocols.s3.S3AssumeRoleProtocol; -import cloud.katta.testsetup.AbstractHubTest; -import cloud.katta.workflows.exceptions.AccessException; -import cloud.katta.workflows.exceptions.SecurityFailure; -import com.nimbusds.jose.JOSEException; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.times; - -class CreateVaultServiceTest { - - @Test - void createVault() throws AccessException, SecurityFailure, BackgroundException, ApiException, JOSEException, IOException, URISyntaxException { - final HubSession hubSession = Mockito.mock(HubSession.class); - final Host hub = Mockito.mock(Host.class); - final VaultResourceApi vaults = Mockito.mock(VaultResourceApi.class); - final UsersResourceApi users = Mockito.mock(UsersResourceApi.class); - final ConfigResourceApi config = Mockito.mock(ConfigResourceApi.class); - - final UserKeys userKeys = UserKeys.create(); - - final HubApiClient apiClient = Mockito.mock(HubApiClient.class); - final StorageProfileResourceApi storageProfiles = Mockito.mock(StorageProfileResourceApi.class); - final StorageResourceApi storage = Mockito.mock(StorageResourceApi.class); - - final UUID vaultId = UUID.randomUUID(); - final UUID storageProfileId = UUID.randomUUID(); - final StorageProfileDto storageProfile = new StorageProfileDto( - new StorageProfileS3STSDto() - .id(storageProfileId) - .protocol(Protocol.S3_STS) - .stsEndpoint("http://audley.end.point") - // AWS has both role arns filled in - .stsRoleArn("arnaud") - .stsRoleArn2("ducret") - - ); - final StorageProfileDtoWrapper storageProfileWrapper = StorageProfileDtoWrapper.coerce(storageProfile); - - Mockito.when(hubSession.getHost()).thenReturn(hub); - Mockito.when(hub.getProtocol()).thenReturn(new HubProtocol() { - @Override - public String getOAuthTokenUrl() { - return "http://tok-tok.dev.null/auth/token"; - } - }); - Mockito.when(hub.getCredentials()).thenReturn(new Credentials()); - Mockito.when(hub.getHostname()).thenReturn("storage"); - Mockito.when(apiClient.getBasePath()).thenReturn("http://nix.com/api"); - Mockito.when(vaults.getApiClient()).thenReturn(apiClient); - - Mockito.when(storageProfiles.apiStorageprofileProfileIdGet(storageProfileId)).thenReturn(storageProfile); - Mockito.when(config.apiConfigGet()).thenReturn(new ConfigDto().keycloakClientIdCryptomatorVaults("hex")); - - final UserDto me = new UserDto(); - Mockito.when(users.apiUsersMeGet(false, false)).thenReturn(me); - - final ProtocolFactory factory = ProtocolFactory.get(); - // Register parent protocol definitions - factory.register( - new HubProtocol(), - new S3AssumeRoleProtocol("PasswordGrant") - ); - // Load bundled profiles - factory.load(new LocalProfilesFinder(factory, new Local(AbstractHubTest.class.getResource("/").toURI().getPath()))); - - final CreateVaultService.CreateVaultModel createVaultModel = new CreateVaultService.CreateVaultModel(vaultId, null, null, null, null, null, null, null, true, 66); - - final CreateVaultService createVaultService = new CreateVaultService(hubSession, config, vaults, storageProfiles, users, storage, CreateVaultService.TemplateUploadService.disabled, CreateVaultService.STSInlinePolicyService.disabled); - - createVaultService.createVault(userKeys, storageProfileWrapper, createVaultModel); - - final boolean expectedMinio = false; - final boolean expectedAWS = true; - Mockito.verify(vaults, times(1)).apiVaultsVaultIdPut(eq(vaultId), any(), eq(expectedMinio), eq(expectedAWS)); - Mockito.verify(vaults, times(1)).apiVaultsVaultIdAccessTokensPost(eq(vaultId), any()); - Mockito.verify(storage, times(1)).apiStorageVaultIdPut(eq(vaultId), any()); - } -} diff --git a/hub/src/test/resources/Katta S3 Storage Configuration.cyberduckprofile b/hub/src/test/resources/Katta S3 Storage Configuration.cyberduckprofile index 8acfb998..594b4b7a 100644 --- a/hub/src/test/resources/Katta S3 Storage Configuration.cyberduckprofile +++ b/hub/src/test/resources/Katta S3 Storage Configuration.cyberduckprofile @@ -14,30 +14,5 @@ Description S3 Storage Configuration using OAuth Credentials from Katta Server - Scheme - http - Default Port - 80 - Scopes - - openid - - OAuth Redirect Url - ${oauth.handler.scheme}:oauth - OAuth Configurable - - OAuth Client Secret - - Username Configurable - - Password Configurable - - Authorization - AuthorizationCode - Properties - - s3.assumerole.rolearn.tag.vaultid.key - Vault - diff --git a/hub/src/test/resources/Katta Server.cyberduckprofile b/hub/src/test/resources/Katta Server.cyberduckprofile index e2739fcb..89e8807f 100644 --- a/hub/src/test/resources/Katta Server.cyberduckprofile +++ b/hub/src/test/resources/Katta Server.cyberduckprofile @@ -33,9 +33,18 @@ ${oauth.handler.scheme}:oauth OAuth Client Secret + OAuth PKCE + Password Configurable Username Configurable + Properties + + katta.userkeys.ttl + 60000 + katta.user.ttl + 60000 + diff --git a/hub/src/test/resources/docker-compose-minio-localhost-hub.yml b/hub/src/test/resources/docker-compose-minio-localhost-hub.yml index 1178a74a..0030717d 100644 --- a/hub/src/test/resources/docker-compose-minio-localhost-hub.yml +++ b/hub/src/test/resources/docker-compose-minio-localhost-hub.yml @@ -226,11 +226,10 @@ services: /mc idp openid ls myminio /mc idp openid info myminio - # if container is restarted, the bucket already exists... - /mc mb myminio/handmade --with-versioning || true - /mc rm --recursive --force myminio/handmade + # if container is restarted, the bucket already exists + /mc rb myminio/handmade || true - echo "createbuckets successful" + echo "Completed MinIO Setup" hub: build: diff --git a/hub/src/test/resources/log4j-test.xml b/hub/src/test/resources/log4j-test.xml index fc4fb139..b1770a03 100644 --- a/hub/src/test/resources/log4j-test.xml +++ b/hub/src/test/resources/log4j-test.xml @@ -31,8 +31,8 @@ + - diff --git a/osx/pom.xml b/osx/pom.xml index 36963181..4c078110 100644 --- a/osx/pom.xml +++ b/osx/pom.xml @@ -22,7 +22,8 @@ ch.cyberduck - binding + osx + ${cyberduck.version} diff --git a/osx/src/main/java/cloud/katta/controller/CreateVaultBookmarkController.java b/osx/src/main/java/cloud/katta/controller/CreateVaultBookmarkController.java deleted file mode 100644 index 22205291..00000000 --- a/osx/src/main/java/cloud/katta/controller/CreateVaultBookmarkController.java +++ /dev/null @@ -1,410 +0,0 @@ -/* - * Copyright (c) 2025 shift7 GmbH. All rights reserved. - */ - -package cloud.katta.controller; - -import ch.cyberduck.binding.Action; -import ch.cyberduck.binding.BundleController; -import ch.cyberduck.binding.Outlet; -import ch.cyberduck.binding.SheetController; -import ch.cyberduck.binding.application.NSButton; -import ch.cyberduck.binding.application.NSImage; -import ch.cyberduck.binding.application.NSImageView; -import ch.cyberduck.binding.application.NSPopUpButton; -import ch.cyberduck.binding.application.NSTextField; -import ch.cyberduck.binding.application.SheetCallback; -import ch.cyberduck.binding.foundation.NSAttributedString; -import ch.cyberduck.core.Controller; -import ch.cyberduck.core.LocaleFactory; -import ch.cyberduck.core.StringAppender; -import ch.cyberduck.core.resources.IconCacheFactory; -import ch.cyberduck.core.threading.AbstractBackgroundAction; - -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.rococoa.Foundation; -import org.rococoa.cocoa.foundation.NSPoint; -import org.rococoa.cocoa.foundation.NSRect; -import org.rococoa.cocoa.foundation.NSSize; - -import java.util.List; -import java.util.UUID; - -import cloud.katta.client.model.Protocol; -import cloud.katta.model.StorageProfileDtoWrapper; -import cloud.katta.workflows.CreateVaultService; - - -/** - * Fetch user input for vault bookmark creation. - */ -public class CreateVaultBookmarkController extends SheetController { - private static final Logger log = LogManager.getLogger(CreateVaultBookmarkController.class.getName()); - - private final String title_; - private final String reason_; - private final String icon_; - private final String vaultNameLabel_; - private final String vaultDescriptionLabel_; - private final String backendLabel_; - private final String regionLabel_; - private final String bucketNameLabel_; - private final String accessKeyIdLabel_; - private final String secretKeyLabel_; - private final String maxWotLevelLabel_; - - private final CreateVaultService.CreateVaultModel model; - private final Callback callback; - - private final Controller controller; - - private final List storageProfiles; - - @Outlet - private NSImageView iconView; - @Outlet - private NSTextField titleField; - @Outlet - private NSTextField messageField; - @Outlet - private NSTextField vaultNameLabel; - @Outlet - private NSTextField vaultNameField; - @Outlet - private NSTextField vaultDescriptionLabel; - @Outlet - private NSTextField vaultDescriptionField; - @Outlet - private NSTextField backendLabel; - @Outlet - private NSPopUpButton backendCombobox; - @Outlet - private NSTextField regionLabel; - @Outlet - private NSPopUpButton regionCombobox; - @Outlet - private NSTextField bucketNameLabel; - @Outlet - private NSTextField bucketNameField; - @Outlet - private NSTextField accessKeyIdLabel; - @Outlet - private NSTextField accessKeyIdField; - @Outlet - private NSTextField secretKeyLabel; - @Outlet - private NSTextField secretKeyField; - @Outlet - private NSTextField automaticAccessGrantCheckboxLabel; - @Outlet - private NSButton automaticAccessGrantCheckbox; - @Outlet - private NSTextField maxWotLevelLabel; - @Outlet - private NSTextField maxWotLevel; - @Outlet - private NSButton helpButton; - @Outlet - private NSButton cancelButton; - @Outlet - private NSButton createVaultButton; - - public CreateVaultBookmarkController(final List storageProfiles, final Controller controller, final CreateVaultService.CreateVaultModel model, final Callback callback) { - this.model = model; - this.callback = callback; - this.title_ = LocaleFactory.localizedString("Create Vault", "Cipherduck"); - this.reason_ = LocaleFactory.localizedString("Enter a name and description for your new vault. You can change these later.", "Cipherduck"); - this.vaultNameLabel_ = LocaleFactory.localizedString("Vault Name", "Cipherduck"); - this.vaultDescriptionLabel_ = LocaleFactory.localizedString("Description (optional)", "Cipherduck"); - this.backendLabel_ = LocaleFactory.localizedString("Vault storage location", "Cipherduck"); - this.regionLabel_ = LocaleFactory.localizedString("Region", "Cipherduck"); - this.icon_ = "cryptomator.tiff"; - this.storageProfiles = storageProfiles; - this.bucketNameLabel_ = LocaleFactory.localizedString("Bucket Name", "Cipherduck"); - this.accessKeyIdLabel_ = LocaleFactory.localizedString("Access Key ID", "Cipherduck"); - this.secretKeyLabel_ = LocaleFactory.localizedString("Secret Key", "Cipherduck"); - this.maxWotLevelLabel_ = LocaleFactory.localizedString("Max WoT Level", "Cipherduck"); - this.controller = controller; - } - - @Override - public void awakeFromNib() { - super.awakeFromNib(); - window.makeFirstResponder(titleField); - updateRegions(); - } - - @Override - protected String getBundleName() { - return "CreateVault"; - } - - public void setIconView(NSImageView iconView) { - this.iconView = iconView; - this.iconView.setImage(IconCacheFactory.get().iconNamed(this.icon_, 64)); - } - - public void setTitleField(NSTextField titleField) { - this.titleField = titleField; - this.updateField(this.titleField, LocaleFactory.localizedString(this.title_, "Cipherduck")); - } - - public void setMessageField(NSTextField messageField) { - this.messageField = messageField; - this.messageField.setSelectable(true); - this.updateField(this.messageField, new StringAppender().append(reason_).toString()); - } - - public void setVaultNameLabel(final NSTextField f) { - this.vaultNameLabel = f; - this.vaultNameLabel.setAttributedStringValue(NSAttributedString.attributedStringWithAttributes( - String.format("%s:", this.vaultNameLabel_), - BundleController.TRUNCATE_TAIL_ATTRIBUTES - )); - } - - public void setVaultNameField(final NSTextField f) { - this.vaultNameField = f; - this.vaultNameField.setStringValue(this.model.vaultName()); - } - - public void setVaultDescriptionLabel(final NSTextField f) { - this.vaultDescriptionLabel = f; - vaultDescriptionLabel.setAttributedStringValue(NSAttributedString.attributedStringWithAttributes( - String.format("%s:", this.vaultDescriptionLabel_), - BundleController.TRUNCATE_TAIL_ATTRIBUTES - )); - } - - public void setVaultDescriptionField(final NSTextField f) { - this.vaultDescriptionField = f; - } - - public void setBackendLabel(final NSTextField f) { - this.backendLabel = f; - backendLabel.setAttributedStringValue(NSAttributedString.attributedStringWithAttributes( - String.format("%s:", this.backendLabel_), - BundleController.TRUNCATE_TAIL_ATTRIBUTES - )); - } - - public void setBackendCombobox(final NSPopUpButton b) { - this.backendCombobox = b; - this.backendCombobox.removeAllItems(); - this.backendCombobox.setTarget(this.id()); - this.backendCombobox.setAction(Foundation.selector("backendComboboxClicked:")); - for(final StorageProfileDtoWrapper backend : storageProfiles) { - this.backendCombobox.addItemWithTitle(backend.getName()); - this.backendCombobox.lastItem().setRepresentedObject(backend.getId().toString()); - } - if(StringUtils.isNotBlank(this.model.storageProfileId())) { - this.backendCombobox.selectItemAtIndex(this.backendCombobox.indexOfItemWithRepresentedObject(this.model.storageProfileId())); - } - } - - @Action - public void backendComboboxClicked(NSPopUpButton sender) { - LocaleFactory.get().setDefault(sender.selectedItem().representedObject()); - updateRegions(); - } - - private void updateRegions() { - synchronized(storageProfiles) { - if(regionCombobox == null) { - return; - } - if(backendCombobox == null) { - return; - } - final String selectedStorageId = this.backendCombobox.selectedItem().representedObject(); - final StorageProfileDtoWrapper config = storageProfiles.stream().filter(c -> c.getId().toString().equals(selectedStorageId)).findFirst().get(); - - final List regions = config.getRegions(); - if(null != regions) { - for(final String region : regions) { - this.regionCombobox.addItemWithTitle(LocaleFactory.localizedString(region, "S3")); - this.regionCombobox.lastItem().setRepresentedObject(region); - if(config.getRegion().equals(region)) { - regionCombobox.selectItem(this.regionCombobox.lastItem()); - } - } - } - final boolean isPermanent = config.getProtocol() == Protocol.S3; - final boolean hiddenIfSTS = !isPermanent; - final boolean hiddenIfPermanent = isPermanent; - bucketNameLabel.setHidden(hiddenIfSTS); - bucketNameField.setHidden(hiddenIfSTS); - accessKeyIdLabel.setHidden(hiddenIfSTS); - accessKeyIdField.setHidden(hiddenIfSTS); - secretKeyLabel.setHidden(hiddenIfSTS); - secretKeyField.setHidden(hiddenIfSTS); - regionCombobox.setHidden(hiddenIfPermanent); - regionLabel.setHidden(hiddenIfPermanent); - final NSRect frame = window.frame(); - double height = frame.size.height.doubleValue(); - final int ROW_HEIGHT = 25; - // isPermanent -> hide region row (1) - // STS -> hide Access Key ID/Secret Key/Bucket Name (3) - height = !isPermanent ? 369.0 - 3 * ROW_HEIGHT : 369.0 - 1 * ROW_HEIGHT; - double width = frame.size.width.doubleValue(); - window.setFrame_display_animate(new NSRect(frame.origin, new NSSize(width, height)), true, true); - // set the bottom row elements relative to new window frame after resizing: - bucketNameLabel.setFrameOrigin(new NSPoint(bucketNameLabel.frame().origin.x.doubleValue(), 62 + ROW_HEIGHT)); - bucketNameField.setFrameOrigin(new NSPoint(bucketNameField.frame().origin.x.doubleValue(), 60 + ROW_HEIGHT)); - secretKeyLabel.setFrameOrigin(new NSPoint(secretKeyLabel.frame().origin.x.doubleValue(), 87 + ROW_HEIGHT)); - secretKeyField.setFrameOrigin(new NSPoint(secretKeyField.frame().origin.x.doubleValue(), 85 + ROW_HEIGHT)); - accessKeyIdLabel.setFrameOrigin(new NSPoint(accessKeyIdLabel.frame().origin.x.doubleValue(), 112 + ROW_HEIGHT)); - accessKeyIdField.setFrameOrigin(new NSPoint(accessKeyIdField.frame().origin.x.doubleValue(), 110 + ROW_HEIGHT)); - automaticAccessGrantCheckbox.setFrameOrigin(new NSPoint(automaticAccessGrantCheckbox.frame().origin.x.doubleValue(), 35 + ROW_HEIGHT)); - maxWotLevel.setFrameOrigin(new NSPoint(maxWotLevel.frame().origin.x.doubleValue(), 35)); - maxWotLevelLabel.setFrameOrigin(new NSPoint(maxWotLevelLabel.frame().origin.x.doubleValue(), 35)); - helpButton.setFrameOrigin(new NSPoint(helpButton.frame().origin.x.doubleValue(), 5 + 4)); - cancelButton.setFrameOrigin(new NSPoint(cancelButton.frame().origin.x.doubleValue(), 5)); - createVaultButton.setFrameOrigin(new NSPoint(createVaultButton.frame().origin.x.doubleValue(), 5)); - } - } - - public void setRegionLabel(final NSTextField f) { - this.regionLabel = f; - this.regionLabel.setAttributedStringValue(NSAttributedString.attributedStringWithAttributes( - String.format("%s:", this.regionLabel_), - BundleController.TRUNCATE_TAIL_ATTRIBUTES - )); - } - - - public void setRegionCombobox(final NSPopUpButton b) { - this.regionCombobox = b; - if(StringUtils.isNotBlank(this.model.region())) { - this.regionCombobox.selectItemAtIndex(this.regionCombobox.indexOfItemWithRepresentedObject(this.model.region())); - } - } - - public void setBucketNameLabel(final NSTextField BucketNameLabel) { - this.bucketNameLabel = BucketNameLabel; - BucketNameLabel.setAttributedStringValue(NSAttributedString.attributedStringWithAttributes( - String.format("%s:", this.bucketNameLabel_), - BundleController.TRUNCATE_TAIL_ATTRIBUTES - )); - } - - public void setBucketNameField(final NSTextField f) { - this.bucketNameField = f; - this.bucketNameField.setStringValue(this.model.bucketName()); - } - - public void setAccessKeyIdLabel(final NSTextField f) { - this.accessKeyIdLabel = f; - f.setAttributedStringValue(NSAttributedString.attributedStringWithAttributes( - String.format("%s:", this.accessKeyIdLabel_), - BundleController.TRUNCATE_TAIL_ATTRIBUTES - )); - this.accessKeyIdField.setStringValue(this.model.accessKeyId()); - } - - public void setAccessKeyIdField(final NSTextField f) { - this.accessKeyIdField = f; - } - - public void setMaxWotLevelLabel(final NSTextField f) { - this.maxWotLevelLabel = f; - f.setAttributedStringValue(NSAttributedString.attributedStringWithAttributes( - String.format("%s:", this.maxWotLevelLabel_), - BundleController.TRUNCATE_TAIL_ATTRIBUTES - )); - - } - - public void setMaxWotLevelField(final NSTextField f) { - this.maxWotLevel = f; - this.maxWotLevel.setStringValue(String.valueOf(this.model.maxWotLevel())); - } - - public void setAutomaticAccessGrantCheckbox(final NSButton b) { - this.automaticAccessGrantCheckbox = b; - this.automaticAccessGrantCheckbox.setState(this.model.automaticAccessGrant() ? 1 : 0); - - } - - public void setSecretKeyLabel(final NSTextField f) { - this.secretKeyLabel = f; - f.setAttributedStringValue(NSAttributedString.attributedStringWithAttributes( - String.format("%s:", this.secretKeyLabel_), - BundleController.TRUNCATE_TAIL_ATTRIBUTES - )); - this.secretKeyField.setStringValue(this.model.secretKey()); - } - - public void setSecretKeyField(final NSTextField f) { - this.secretKeyField = f; - } - - public void setHelpButton(final NSButton b) { - this.helpButton = b; - } - - public void setCancelButton(final NSButton b) { - this.cancelButton = b; - this.cancelButton.setTarget(this.id()); - this.cancelButton.setAction(Foundation.selector("closeSheet:")); - } - - public void setCreateVaultButton(final NSButton b) { - this.createVaultButton = b; - this.createVaultButton.setTarget(this.id()); - this.createVaultButton.setAction(Foundation.selector("closeSheet:")); - } - - @Override - public boolean validate(final int returncode) { - if(StringUtils.isBlank(this.vaultNameField.stringValue())) { - return false; - } - final String selectedStorageId = this.backendCombobox.selectedItem().representedObject(); - final StorageProfileDtoWrapper config = storageProfiles.stream().filter(c -> c.getId().toString().equals(selectedStorageId)).findFirst().get(); - final boolean isPermanent = config.getProtocol() == Protocol.S3; - if(isPermanent) { - if(StringUtils.isBlank(this.accessKeyIdField.stringValue())) { - return false; - } - if(StringUtils.isBlank(this.secretKeyField.stringValue())) { - return false; - } - if(StringUtils.isBlank(this.bucketNameField.stringValue())) { - return false; - } - } - return true; - } - - @Override - public void callback(final int returncode) { - if(returncode != SheetCallback.DEFAULT_OPTION) { - return; - } - controller.background(new AbstractBackgroundAction() { - @Override - public Void run() { - final CreateVaultService.CreateVaultModel m = new CreateVaultService.CreateVaultModel(UUID.randomUUID(), vaultNameField.stringValue(), - vaultDescriptionField.stringValue(), - backendCombobox.selectedItem().representedObject(), - StringUtils.isNotBlank(accessKeyIdField.stringValue()) ? accessKeyIdField.stringValue() : null, - StringUtils.isNotBlank(secretKeyField.stringValue()) ? secretKeyField.stringValue() : null, - StringUtils.isNotBlank(bucketNameField.stringValue()) ? bucketNameField.stringValue() : null, - regionCombobox.selectedItem().representedObject(), - automaticAccessGrantCheckbox.integerValue() == 1, - StringUtils.isNotBlank(maxWotLevel.stringValue()) ? Integer.parseInt(maxWotLevel.stringValue()) : 0 - ); - callback.callback(m); - return null; - } - }); - } - - public interface Callback { - void callback(final CreateVaultService.CreateVaultModel selected); - } -} - diff --git a/osx/src/main/java/cloud/katta/controller/DeviceSetupController.java b/osx/src/main/java/cloud/katta/controller/DeviceSetupController.java new file mode 100644 index 00000000..3f1f56a7 --- /dev/null +++ b/osx/src/main/java/cloud/katta/controller/DeviceSetupController.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.controller; + +import ch.cyberduck.binding.Action; +import ch.cyberduck.binding.AlertController; +import ch.cyberduck.binding.Outlet; +import ch.cyberduck.binding.application.NSAlert; +import ch.cyberduck.binding.application.NSCell; +import ch.cyberduck.binding.application.NSControl; +import ch.cyberduck.binding.application.NSSecureTextField; +import ch.cyberduck.binding.application.NSTextField; +import ch.cyberduck.binding.application.NSView; +import ch.cyberduck.binding.application.SheetCallback; +import ch.cyberduck.binding.foundation.NSNotification; +import ch.cyberduck.binding.foundation.NSNotificationCenter; +import ch.cyberduck.core.LocaleFactory; +import ch.cyberduck.core.StringAppender; +import ch.cyberduck.core.preferences.PreferencesFactory; + +import org.apache.commons.lang3.StringUtils; +import org.rococoa.Foundation; + +import cloud.katta.model.AccountKeyAndDeviceName; + +public class DeviceSetupController extends AlertController { + + private final AccountKeyAndDeviceName accountKeyAndDeviceName; + + @Outlet + private final NSTextField accountKeyField = NSSecureTextField.textFieldWithString(StringUtils.EMPTY); + + @Outlet + private final NSTextField deviceNameField = NSTextField.textFieldWithString(StringUtils.EMPTY); + + public DeviceSetupController(final AccountKeyAndDeviceName accountKeyAndDeviceName) { + this.accountKeyAndDeviceName = accountKeyAndDeviceName; + } + + @Override + public NSAlert loadAlert() { + final NSAlert alert = NSAlert.alert(); + alert.setAlertStyle(NSAlert.NSInformationalAlertStyle); + alert.setMessageText(LocaleFactory.localizedString("Authorization Required", "Hub")); + alert.setInformativeText(new StringAppender() + .append(LocaleFactory.localizedString("This is your first login on this device.", "Hub")) + .append(LocaleFactory.localizedString("Your Account Key is required to link this browser to your account.", "Hub")).toString()); + alert.addButtonWithTitle(LocaleFactory.localizedString("Finish Setup", "Hub")); + alert.addButtonWithTitle(LocaleFactory.localizedString("Cancel", "Alert")); + alert.setShowsSuppressionButton(false); + alert.suppressionButton().setTitle(LocaleFactory.localizedString("Add to Keychain", "Login")); + alert.suppressionButton().setState(PreferencesFactory.get().getBoolean("cryptomator.vault.keychain") ? NSCell.NSOnState : NSCell.NSOffState); + return alert; + } + + @Override + public NSView getAccessoryView(final NSAlert alert) { + final NSView accessoryView = NSView.create(); + { + accountKeyField.cell().setPlaceholderString(LocaleFactory.localizedString("Account Key", "Hub")); + accountKeyField.setToolTip(LocaleFactory.localizedString("Your Account Key is required to authorize this device.", "Hub")); + NSNotificationCenter.defaultCenter().addObserver(this.id(), + Foundation.selector("accountKeyFieldTextDidChange:"), + NSControl.NSControlTextDidChangeNotification, + accountKeyField.id()); + this.addAccessorySubview(accessoryView, accountKeyField); + } + { + updateField(deviceNameField, accountKeyAndDeviceName.deviceName(), TRUNCATE_MIDDLE_ATTRIBUTES); + deviceNameField.cell().setPlaceholderString(LocaleFactory.localizedString("Device Name", "Hub")); + deviceNameField.setToolTip(LocaleFactory.localizedString("Name this device for easy identification in your authorized devices list.", "Hub")); + NSNotificationCenter.defaultCenter().addObserver(this.id(), + Foundation.selector("deviceNameFieldTextDidChange:"), + NSControl.NSControlTextDidChangeNotification, + deviceNameField.id()); + this.addAccessorySubview(accessoryView, deviceNameField); + } + return accessoryView; + } + + @Override + protected void focus(final NSAlert alert) { + super.focus(alert); + window.makeFirstResponder(accountKeyField); + } + + @Override + public boolean validate(final int option) { + if(SheetCallback.DEFAULT_OPTION == option) { + return StringUtils.isNotBlank(accountKeyField.stringValue()); + } + return true; + } + + @Action + public void accountKeyFieldTextDidChange(final NSNotification sender) { + accountKeyAndDeviceName.withAccountKey(StringUtils.trim(accountKeyField.stringValue())); + } + + @Action + public void deviceNameFieldTextDidChange(final NSNotification sender) { + accountKeyAndDeviceName.withDeviceName(StringUtils.trim(deviceNameField.stringValue())); + } +} + diff --git a/osx/src/main/java/cloud/katta/controller/DeviceSetupWithAccountKeyController.java b/osx/src/main/java/cloud/katta/controller/DeviceSetupWithAccountKeyController.java deleted file mode 100644 index 71a90ae4..00000000 --- a/osx/src/main/java/cloud/katta/controller/DeviceSetupWithAccountKeyController.java +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright (c) 2025 shift7 GmbH. All rights reserved. - */ - -package cloud.katta.controller; - -import ch.cyberduck.binding.Action; -import ch.cyberduck.binding.BundleController; -import ch.cyberduck.binding.Outlet; -import ch.cyberduck.binding.SheetController; -import ch.cyberduck.binding.application.NSControl; -import ch.cyberduck.binding.application.NSImage; -import ch.cyberduck.binding.application.NSImageView; -import ch.cyberduck.binding.application.NSSecureTextField; -import ch.cyberduck.binding.application.NSTextField; -import ch.cyberduck.binding.foundation.NSAttributedString; -import ch.cyberduck.binding.foundation.NSNotification; -import ch.cyberduck.binding.foundation.NSNotificationCenter; -import ch.cyberduck.core.LocaleFactory; -import ch.cyberduck.core.StringAppender; -import ch.cyberduck.core.resources.IconCacheFactory; - -import org.apache.commons.lang3.StringUtils; -import org.rococoa.Foundation; - -import cloud.katta.model.AccountKeyAndDeviceName; - - -public class DeviceSetupWithAccountKeyController extends SheetController { - protected final NSNotificationCenter notificationCenter - = NSNotificationCenter.defaultCenter(); - private final String title; - private final String reason; - private final String icon; - private final AccountKeyAndDeviceName accountKeyAndDeviceName; - - private final String setupCodeLabel_; - private final String setupCodeHint_; - private final String deviceNameLabel_; - private final String deviceNameHint_; - - @Outlet - private NSImageView iconView; - @Outlet - private NSTextField titleField; - @Outlet - private NSTextField messageField; - - @Outlet - protected NSTextField setupCodeField; - @Outlet - protected NSTextField setupCodeLabel; - @Outlet - protected NSTextField setupCodeHint; - - @Outlet - protected NSTextField deviceNameField; - @Outlet - protected NSTextField deviceNameLabel; - @Outlet - protected NSTextField deviceNameHint; - - public DeviceSetupWithAccountKeyController(final AccountKeyAndDeviceName accountKeyAndDeviceName) { - this.accountKeyAndDeviceName = accountKeyAndDeviceName; - this.title = LocaleFactory.localizedString("Authorization Required", "Cipherduck"); - this.reason = LocaleFactory.localizedString("This is your first login on this device. ", "Cipherduck"); - this.setupCodeLabel_ = LocaleFactory.localizedString("Account Key", "Cipherduck"); - this.setupCodeHint_ = LocaleFactory.localizedString("Your Account Key is required to authorize this device.", "Cipherduck"); - this.deviceNameLabel_ = LocaleFactory.localizedString("Device Name", "Cipherduck"); - this.deviceNameHint_ = LocaleFactory.localizedString("Name this device for easy identification in your authorized devices list.", "Cipherduck"); - this.icon = "cryptomator.tiff"; - } - - @Override - public void awakeFromNib() { - super.awakeFromNib(); - window.makeFirstResponder(titleField); - } - - @Override - protected String getBundleName() { - return "DeviceSetupWithAccountKey"; - } - - public void setIconView(NSImageView iconView) { - this.iconView = iconView; - this.iconView.setImage(IconCacheFactory.get().iconNamed(this.icon, 64)); - } - - public void setTitleField(NSTextField titleField) { - this.titleField = titleField; - this.updateField(this.titleField, LocaleFactory.localizedString(title, "Credentials")); - } - - public void setMessageField(NSTextField messageField) { - this.messageField = messageField; - this.messageField.setSelectable(true); - this.updateField(this.messageField, new StringAppender().append(reason).toString()); - } - - public void setDeviceNameField(final NSTextField field) { - this.deviceNameField = field; - this.deviceNameField.setStringValue(StringUtils.isNotBlank(this.accountKeyAndDeviceName.deviceName()) ? this.accountKeyAndDeviceName.deviceName() : StringUtils.EMPTY); - this.notificationCenter.addObserver(this.id(), - Foundation.selector("deviceNameInputDidChange:"), - NSControl.NSControlTextDidChangeNotification, - field.id()); - } - - @Action - public void deviceNameInputDidChange(final NSNotification sender) { - accountKeyAndDeviceName.withDeviceName(StringUtils.trim(deviceNameField.stringValue())); - } - - public void setDeviceNameLabel(final NSTextField deviceNameLabel) { - this.deviceNameLabel = deviceNameLabel; - deviceNameLabel.setAttributedStringValue(NSAttributedString.attributedStringWithAttributes( - String.format("%s:", this.deviceNameLabel_), - BundleController.TRUNCATE_TAIL_ATTRIBUTES - )); - } - - public void setDeviceNameHint(NSTextField messageField) { - this.deviceNameHint = messageField; - this.updateField(this.deviceNameHint, LocaleFactory.localizedString(this.deviceNameHint_, "Cipherduck")); - } - - public void setSetupCodeLabel(final NSTextField setSetupCodeLabel) { - this.setupCodeLabel = setSetupCodeLabel; - setSetupCodeLabel.setAttributedStringValue(NSAttributedString.attributedStringWithAttributes( - String.format("%s:", this.setupCodeLabel_), - BundleController.TRUNCATE_TAIL_ATTRIBUTES - )); - } - - public void setSetupCodeField(NSSecureTextField field) { - this.setupCodeField = field; - this.notificationCenter.addObserver(this.id(), - Foundation.selector("setupCodeInputDidChange:"), - NSControl.NSControlTextDidChangeNotification, - field.id()); - } - - @Action - public void setupCodeInputDidChange(final NSNotification sender) { - this.accountKeyAndDeviceName.withAccountKey(StringUtils.trim(setupCodeField.stringValue())); - } - - public void setSetupCodeHint(NSTextField messageField) { - this.setupCodeHint = messageField; - this.updateField(this.setupCodeHint, LocaleFactory.localizedString(this.setupCodeHint_, "Cipherduck")); - } - - - @Override - public void callback(final int returncode) { - // - } - -} - diff --git a/osx/src/main/java/cloud/katta/controller/FirstLoginController.java b/osx/src/main/java/cloud/katta/controller/FirstLoginController.java index 0d00b83c..ec19b027 100644 --- a/osx/src/main/java/cloud/katta/controller/FirstLoginController.java +++ b/osx/src/main/java/cloud/katta/controller/FirstLoginController.java @@ -5,184 +5,98 @@ package cloud.katta.controller; import ch.cyberduck.binding.Action; +import ch.cyberduck.binding.AlertController; import ch.cyberduck.binding.Outlet; -import ch.cyberduck.binding.SheetController; -import ch.cyberduck.binding.application.*; +import ch.cyberduck.binding.application.NSAlert; +import ch.cyberduck.binding.application.NSCell; +import ch.cyberduck.binding.application.NSControl; +import ch.cyberduck.binding.application.NSTextField; +import ch.cyberduck.binding.application.NSView; +import ch.cyberduck.binding.application.SheetCallback; import ch.cyberduck.binding.foundation.NSNotification; import ch.cyberduck.binding.foundation.NSNotificationCenter; import ch.cyberduck.core.LocaleFactory; import ch.cyberduck.core.StringAppender; -import ch.cyberduck.core.resources.IconCacheFactory; -import ch.cyberduck.ui.pasteboard.PasteboardService; -import ch.cyberduck.ui.pasteboard.PasteboardServiceFactory; -import cloud.katta.model.AccountKeyAndDeviceName; +import ch.cyberduck.core.preferences.PreferencesFactory; + import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import org.rococoa.Foundation; -import org.rococoa.ID; - -public class FirstLoginController extends SheetController { - private static final Logger log = LogManager.getLogger(FirstLoginController.class.getName()); - - protected final NSNotificationCenter notificationCenter - = NSNotificationCenter.defaultCenter(); - private final String title; - private final String reason; - private final String icon; - - private final String setupCodeHint_; - private final String setupCodeHintIcon_; - private final String setupCodeHint2_; - private final String setupCodeHint2Icon_; - private final String deviceNameHint_; - private final String deviceNameHintIcon_; - private final AccountKeyAndDeviceName accountKeyAndDeviceName; - - @Outlet - private NSImageView iconView; - @Outlet - private NSTextField titleField; - @Outlet - private NSTextField messageField; +import cloud.katta.model.AccountKeyAndDeviceName; - @Outlet - protected NSTextField setupCodeField; - @Outlet - protected NSTextField setupCodeHint; - @Outlet - private NSImageView setupCodeHintIcon; +public class FirstLoginController extends AlertController { - @Outlet - protected NSTextField setupCodeHint2; - @Outlet - private NSImageView setupCodeHint2Icon; + private final AccountKeyAndDeviceName accountKeyAndDeviceName; @Outlet - protected NSTextField deviceNameHint; - @Outlet - private NSImageView deviceNameHintIcon; + private final NSTextField accountKeyField = NSTextField.textFieldWithString(StringUtils.EMPTY); @Outlet - protected NSTextField deviceNameField; - private NSButton accountKeyStoredSecurelyCheckbox; - private NSButton finishSetupButton; + private final NSTextField deviceNameField = NSTextField.textFieldWithString(StringUtils.EMPTY); public FirstLoginController(final AccountKeyAndDeviceName accountKeyAndDeviceName) { - this.icon = "cryptomator.tiff"; - this.title = LocaleFactory.localizedString("Welcome to Cipherduck", "Cipherduck"); - this.reason = LocaleFactory.localizedString("On first login, every user gets a unique Account Key.", "Cipherduck"); - this.setupCodeHint_ = LocaleFactory.localizedString("Your Account Key is required to login from other apps or browsers.", "Cipherduck"); - this.setupCodeHint2_ = LocaleFactory.localizedString("You can see a list of authorized apps on your profile page in Cipherduck Hub.", "Cipherduck"); - this.deviceNameHint_ = LocaleFactory.localizedString("This device will be added to this list as:", "Cipherduck"); this.accountKeyAndDeviceName = accountKeyAndDeviceName; - this.setupCodeHintIcon_ = "KeyIcon.tiff"; - this.setupCodeHint2Icon_ = "ListBulletIcon.tiff"; - this.deviceNameHintIcon_ = "ComputerDesktopIcon.tiff"; } @Override - public void awakeFromNib() { - super.awakeFromNib(); - window.makeFirstResponder(titleField); + public NSAlert loadAlert() { + final NSAlert alert = NSAlert.alert(); + alert.setAlertStyle(NSAlert.NSInformationalAlertStyle); + alert.setMessageText(LocaleFactory.localizedString("Account Key", "Hub")); + alert.setInformativeText(new StringAppender() + .append(LocaleFactory.localizedString("On first login, every user gets a unique Account Key", "Hub")) + .append(LocaleFactory.localizedString("Your Account Key is required to login from other apps or browsers", "Hub")) + .append(LocaleFactory.localizedString("You can see a list of authorized apps on your profile page", "Hub")).toString()); + alert.addButtonWithTitle(LocaleFactory.localizedString("Finish Setup", "Hub")); + alert.addButtonWithTitle(LocaleFactory.localizedString("Cancel", "Alert")); + alert.setShowsSuppressionButton(false); + alert.suppressionButton().setTitle(LocaleFactory.localizedString("Add to Keychain", "Login")); + alert.suppressionButton().setState(PreferencesFactory.get().getBoolean("cryptomator.vault.keychain") ? NSCell.NSOnState : NSCell.NSOffState); + return alert; } @Override - protected String getBundleName() { - return "FirstLogin"; - } - - public void setIconView(NSImageView iconView) { - this.iconView = iconView; - this.iconView.setImage(IconCacheFactory.get().iconNamed(this.icon, 64)); - } - - public void setTitleField(NSTextField titleField) { - this.titleField = titleField; - this.updateField(this.titleField, LocaleFactory.localizedString(title, "Cipherduck")); - } - - public void setMessageField(NSTextField messageField) { - this.messageField = messageField; - this.messageField.setSelectable(true); - this.updateField(this.messageField, new StringAppender().append(reason).toString()); - } - - public void setSetupCodeField(final NSTextField field) { - this.setupCodeField = field; - this.setupCodeField.setStringValue(this.accountKeyAndDeviceName.accountKey()); - } - - public void setSetupCodeHint(NSTextField messageField) { - this.setupCodeHint = messageField; - this.updateField(this.setupCodeHint, LocaleFactory.localizedString(this.setupCodeHint_, "Cipherduck")); - } - - public void setSetupCodeHintIcon(NSImageView iconView) { - this.setupCodeHintIcon = iconView; - this.setupCodeHintIcon.setImage(IconCacheFactory.get().iconNamed(this.setupCodeHintIcon_, 64)); - } - - public void setSetupCodeHint2(NSTextField messageField) { - this.setupCodeHint2 = messageField; - this.updateField(this.setupCodeHint2, LocaleFactory.localizedString(this.setupCodeHint2_, "Cipherduck")); - } - - public void setSetupCodeHint2Icon(NSImageView iconView) { - this.setupCodeHint2Icon = iconView; - this.setupCodeHint2Icon.setImage(IconCacheFactory.get().iconNamed(this.setupCodeHint2Icon_, 64)); - } - - public void setDeviceNameHint(NSTextField messageField) { - this.deviceNameHint = messageField; - this.updateField(this.deviceNameHint, LocaleFactory.localizedString(this.deviceNameHint_, "Cipherduck")); - } - - public void setDeviceNameHintIcon(NSImageView iconView) { - this.deviceNameHintIcon = iconView; - this.deviceNameHintIcon.setImage(IconCacheFactory.get().iconNamed(this.deviceNameHintIcon_, 64)); - } - - public void setDeviceNameField(final NSTextField field) { - this.deviceNameField = field; - this.deviceNameField.setStringValue(StringUtils.isNotBlank(this.accountKeyAndDeviceName.deviceName()) ? this.accountKeyAndDeviceName.deviceName() : StringUtils.EMPTY); - this.notificationCenter.addObserver(this.id(), - Foundation.selector("deviceNameInputDidChange:"), - NSControl.NSControlTextDidChangeNotification, - field.id()); - } - - @Action - public void deviceNameInputDidChange(final NSNotification sender) { - accountKeyAndDeviceName.withDeviceName(StringUtils.trim(deviceNameField.stringValue())); + public NSView getAccessoryView(final NSAlert alert) { + final NSView accessoryView = NSView.create(); + { + accountKeyField.setEditable(false); + accountKeyField.setSelectable(true); + accountKeyField.cell().setWraps(false); + this.updateField(accountKeyField, accountKeyAndDeviceName.accountKey(), TRUNCATE_MIDDLE_ATTRIBUTES); + this.addAccessorySubview(accessoryView, accountKeyField); + } + + { + this.updateField(deviceNameField, accountKeyAndDeviceName.deviceName()); + deviceNameField.cell().setPlaceholderString(LocaleFactory.localizedString("Device Name", "Hub")); + deviceNameField.setToolTip(LocaleFactory.localizedString("Name this device for easy identification in your authorized devices list.", "Hub")); + NSNotificationCenter.defaultCenter().addObserver(this.id(), + Foundation.selector("deviceNameFieldTextDidChange:"), + NSControl.NSControlTextDidChangeNotification, + deviceNameField.id()); + this.addAccessorySubview(accessoryView, deviceNameField); + } + + return accessoryView; } @Override - public void callback(final int returncode) { - // - } - - @Action - public void copyToClipboard(final ID sender) { - PasteboardServiceFactory.get().add(PasteboardService.Type.string, accountKeyAndDeviceName.accountKey()); - } - - public void setFinishSetupButton(final NSButton button) { - this.finishSetupButton = button; - this.finishSetupButton.setEnabled(false); + protected void focus(final NSAlert alert) { + super.focus(alert); + window.makeFirstResponder(deviceNameField); } - public void setAccountKeyStoredSecurelyCheckbox(final NSButton button) { - this.accountKeyStoredSecurelyCheckbox = button; - this.accountKeyStoredSecurelyCheckbox.setTarget(this.id()); - this.accountKeyStoredSecurelyCheckbox.setAction(Foundation.selector("accountKeyStoredSecurelyCheckboxClicked:")); + @Override + public boolean validate(final int option) { + if(SheetCallback.DEFAULT_OPTION == option) { + return StringUtils.isNotBlank(deviceNameField.stringValue()); + } + return true; } @Action - public void accountKeyStoredSecurelyCheckboxClicked(final NSButton sender) { - this.finishSetupButton.setEnabled(sender.state() == NSCell.NSOnState); + public void deviceNameFieldTextDidChange(final NSNotification sender) { + accountKeyAndDeviceName.withDeviceName(StringUtils.trim(deviceNameField.stringValue())); } - } diff --git a/osx/src/main/java/cloud/katta/controller/PromptDeviceSetupCallback.java b/osx/src/main/java/cloud/katta/controller/PromptDeviceSetupCallback.java new file mode 100644 index 00000000..e53ba4d2 --- /dev/null +++ b/osx/src/main/java/cloud/katta/controller/PromptDeviceSetupCallback.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.controller; + +import ch.cyberduck.binding.ProxyController; +import ch.cyberduck.binding.SheetController; +import ch.cyberduck.binding.application.SheetCallback; +import ch.cyberduck.core.Host; +import ch.cyberduck.core.exception.ConnectionCanceledException; +import ch.cyberduck.ui.cocoa.callback.PromptLoginCallback; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import cloud.katta.core.DeviceSetupCallback; +import cloud.katta.model.AccountKeyAndDeviceName; +import cloud.katta.workflows.exceptions.AccessException; + +public class PromptDeviceSetupCallback extends PromptLoginCallback implements DeviceSetupCallback { + private static final Logger log = LogManager.getLogger(PromptDeviceSetupCallback.class.getName()); + + private final ProxyController controller; + + public PromptDeviceSetupCallback(final ProxyController controller) { + super(controller); + this.controller = controller; + } + + @Override + public AccountKeyAndDeviceName displayAccountKeyAndAskDeviceName(final Host bookmark, final AccountKeyAndDeviceName accountKeyAndDeviceName) throws AccessException { + if(log.isDebugEnabled()) { + log.debug(String.format("Display Account Key for %s", bookmark)); + } + final SheetController sheet = new FirstLoginController(accountKeyAndDeviceName); + switch(controller.alert(sheet)) { + case SheetCallback.CANCEL_OPTION: + case SheetCallback.ALTERNATE_OPTION: + throw new AccessException(new ConnectionCanceledException()); + } + return accountKeyAndDeviceName; + } + + @Override + public AccountKeyAndDeviceName askForAccountKeyAndDeviceName(final Host bookmark, final String initialDeviceName) throws AccessException { + if(log.isDebugEnabled()) { + log.debug(String.format("Ask for Account Key for %s", bookmark)); + } + final AccountKeyAndDeviceName accountKeyAndDeviceName = new AccountKeyAndDeviceName().withDeviceName(initialDeviceName); + final DeviceSetupController sheet = new DeviceSetupController(accountKeyAndDeviceName); + switch(controller.alert(sheet)) { + case SheetCallback.CANCEL_OPTION: + case SheetCallback.ALTERNATE_OPTION: + throw new AccessException(new ConnectionCanceledException()); + } + return accountKeyAndDeviceName; + } + + @SuppressWarnings("unchecked") + @Override + public T getFeature(final Class type) { + if(type == DeviceSetupCallback.class) { + return (T) this; + } + return null; + } +} diff --git a/pom.xml b/pom.xml index 013676ab..0eb59da5 100644 --- a/pom.xml +++ b/pom.xml @@ -144,6 +144,11 @@ + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + @@ -208,6 +213,19 @@ + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + verify + + jar-no-fork + + + +