From b8058e7561d16f061ab025fbb973e7ad560b058d Mon Sep 17 00:00:00 2001 From: James Muehlner Date: Wed, 20 Jul 2022 00:18:26 +0000 Subject: [PATCH] GUACAMOLE-1643: Validate/translate KSM configs and one-time tokens on connection group save. --- .../vault/user/VaultDirectoryService.java | 140 ++++++++++ .../vault/user/VaultUserContext.java | 54 ++++ .../ksm/KsmAuthenticationProviderModule.java | 3 + .../vault/ksm/user/KsmDirectoryService.java | 253 ++++++++++++++++++ .../src/main/resources/translations/en.json | 5 +- 5 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultDirectoryService.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectoryService.java diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultDirectoryService.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultDirectoryService.java new file mode 100644 index 000000000..700d9d3be --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultDirectoryService.java @@ -0,0 +1,140 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.guacamole.vault.user; + +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.auth.ActiveConnection; +import org.apache.guacamole.net.auth.Connection; +import org.apache.guacamole.net.auth.ConnectionGroup; +import org.apache.guacamole.net.auth.Directory; +import org.apache.guacamole.net.auth.SharingProfile; +import org.apache.guacamole.net.auth.User; +import org.apache.guacamole.net.auth.UserGroup; + +/** + * A service that allows a vault implementation to override the directory + * for any entity that a user context may return. + */ +public abstract class VaultDirectoryService { + + /** + * Given an existing User Directory, return a new Directory for + * this vault implementation. + * + * @return + * A new User Directory based on the provided Directory. + * + * @throws GuacamoleException + * If an error occurs while creating the Directory. + */ + public Directory getUserDirectory( + Directory underlyingDirectory) throws GuacamoleException { + + // By default, the provided directly will be returned unchanged + return underlyingDirectory; + } + + /** + * Given an existing UserGroup Directory, return a new Directory for + * this vault implementation. + * + * @return + * A new UserGroup Directory based on the provided Directory. + * + * @throws GuacamoleException + * If an error occurs while creating the Directory. + */ + public Directory getUserGroupDirectory( + Directory underlyingDirectory) throws GuacamoleException { + + // Unless overriden in the vault implementation, the underlying directory + // will be returned directly + return underlyingDirectory; + } + + /** + * Given an existing Connection Directory, return a new Directory for + * this vault implementation. + * + * @return + * A new Connection Directory based on the provided Directory. + * + * @throws GuacamoleException + * If an error occurs while creating the Directory. + */ + public Directory getConnectionDirectory( + Directory underlyingDirectory) throws GuacamoleException { + + // By default, the provided directly will be returned unchanged + return underlyingDirectory; + } + + /** + * Given an existing ConnectionGroup Directory, return a new Directory for + * this vault implementation. + * + * @return + * A new ConnectionGroup Directory based on the provided Directory. + * + * @throws GuacamoleException + * If an error occurs while creating the Directory. + */ + public Directory getConnectionGroupDirectory( + Directory underlyingDirectory) throws GuacamoleException { + + // By default, the provided directly will be returned unchanged + return underlyingDirectory; + } + + /** + * Given an existing ActiveConnection Directory, return a new Directory for + * this vault implementation. + * + * @return + * A new ActiveConnection Directory based on the provided Directory. + * + * @throws GuacamoleException + * If an error occurs while creating the Directory. + */ + public Directory getActiveConnectionDirectory( + Directory underlyingDirectory) throws GuacamoleException { + + // By default, the provided directly will be returned unchanged + return underlyingDirectory; + } + + /** + * Given an existing SharingProfile Directory, return a new Directory for + * this vault implementation. + * + * @return + * A new SharingProfile Directory based on the provided Directory. + * + * @throws GuacamoleException + * If an error occurs while creating the Directory. + */ + public Directory getSharingProfileDirectory( + Directory underlyingDirectory) throws GuacamoleException { + + // By default, the provided directly will be returned unchanged + return underlyingDirectory; + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java index dbfbbb9cb..8e8f66853 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java @@ -35,11 +35,16 @@ import java.util.stream.Stream; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleServerException; import org.apache.guacamole.form.Form; +import org.apache.guacamole.net.auth.ActiveConnection; import org.apache.guacamole.net.auth.Connectable; import org.apache.guacamole.net.auth.Connection; import org.apache.guacamole.net.auth.ConnectionGroup; +import org.apache.guacamole.net.auth.Directory; +import org.apache.guacamole.net.auth.SharingProfile; import org.apache.guacamole.net.auth.TokenInjectingUserContext; +import org.apache.guacamole.net.auth.User; import org.apache.guacamole.net.auth.UserContext; +import org.apache.guacamole.net.auth.UserGroup; import org.apache.guacamole.protocol.GuacamoleConfiguration; import org.apache.guacamole.token.GuacamoleTokenUndefinedException; import org.apache.guacamole.token.TokenFilter; @@ -137,6 +142,13 @@ public class VaultUserContext extends TokenInjectingUserContext { @Inject private VaultAttributeService attributeService; + /** + * Service for modifying any underlying directories for the current + * vault implementation. + */ + @Inject + private VaultDirectoryService directoryService; + /** * Creates a new VaultUserContext which automatically injects tokens * containing values of secrets retrieved from a vault. The given @@ -438,4 +450,46 @@ public class VaultUserContext extends TokenInjectingUserContext { } + @Override + public Directory getUserDirectory() throws GuacamoleException { + + // Defer to the vault-specific directory service + return directoryService.getUserDirectory(super.getUserDirectory()); + } + + @Override + public Directory getUserGroupDirectory() throws GuacamoleException { + + // Defer to the vault-specific directory service + return directoryService.getUserGroupDirectory(super.getUserGroupDirectory()); + } + + @Override + public Directory getConnectionDirectory() throws GuacamoleException { + + // Defer to the vault-specific directory service + return directoryService.getConnectionDirectory(super.getConnectionDirectory()); + } + + @Override + public Directory getConnectionGroupDirectory() throws GuacamoleException { + + // Defer to the vault-specific directory service + return directoryService.getConnectionGroupDirectory(super.getConnectionGroupDirectory()); + } + + @Override + public Directory getActiveConnectionDirectory() throws GuacamoleException { + + // Defer to the vault-specific directory service + return directoryService.getActiveConnectionDirectory(super.getActiveConnectionDirectory()); + } + + @Override + public Directory getSharingProfileDirectory() throws GuacamoleException { + + // Defer to the vault-specific directory service + return directoryService.getSharingProfileDirectory(super.getSharingProfileDirectory()); + } + } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java index 6a7a70c83..3c28553e0 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java @@ -24,12 +24,14 @@ import org.apache.guacamole.vault.VaultAuthenticationProviderModule; import org.apache.guacamole.vault.ksm.conf.KsmAttributeService; import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService; import org.apache.guacamole.vault.ksm.secret.KsmSecretService; +import org.apache.guacamole.vault.ksm.user.KsmDirectoryService; import org.apache.guacamole.vault.conf.VaultAttributeService; import org.apache.guacamole.vault.conf.VaultConfigurationService; import org.apache.guacamole.vault.ksm.secret.KsmClient; import org.apache.guacamole.vault.ksm.secret.KsmClientFactory; import org.apache.guacamole.vault.ksm.secret.KsmRecordService; import org.apache.guacamole.vault.secret.VaultSecretService; +import org.apache.guacamole.vault.user.VaultDirectoryService; import com.google.inject.assistedinject.FactoryModuleBuilder; @@ -58,6 +60,7 @@ public class KsmAuthenticationProviderModule bind(VaultAttributeService.class).to(KsmAttributeService.class); bind(VaultConfigurationService.class).to(KsmConfigurationService.class); bind(VaultSecretService.class).to(KsmSecretService.class); + bind(VaultDirectoryService.class).to(KsmDirectoryService.class); // Bind factory for creating KSM Clients install(new FactoryModuleBuilder() diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectoryService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectoryService.java new file mode 100644 index 000000000..fc4a96213 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectoryService.java @@ -0,0 +1,253 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.guacamole.vault.ksm.user; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.language.TranslatableGuacamoleClientException; +import org.apache.guacamole.net.auth.Attributes; +import org.apache.guacamole.net.auth.ConnectionGroup; +import org.apache.guacamole.net.auth.DelegatingDirectory; +import org.apache.guacamole.net.auth.Directory; +import org.apache.guacamole.vault.ksm.conf.KsmAttributeService; +import org.apache.guacamole.vault.ksm.conf.KsmConfig; +import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService; +import org.apache.guacamole.vault.user.VaultDirectoryService; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.inject.Inject; +import com.keepersecurity.secretsManager.core.InMemoryStorage; +import com.keepersecurity.secretsManager.core.SecretsManager; +import com.keepersecurity.secretsManager.core.SecretsManagerOptions; + +/** + * A KSM-specific vault directory service that wraps the connection group directory + * to enable automatic translation of KSM one-time tokens into base64-encoded JSON + * config bundles. + */ +public class KsmDirectoryService extends VaultDirectoryService { + + /** + * Service for retrieving KSM configuration details. + */ + @Inject + private KsmConfigurationService configurationService; + + /** + * A singleton ObjectMapper for converting a Map to a JSON string when + * generating a base64-encoded JSON KSM config blob. + */ + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * All expected fields in the KSM configuration JSON blob. + */ + private static final List EXPECTED_KSM_FIELDS = ( + Collections.unmodifiableList(Arrays.asList( + SecretsManager.KEY_HOSTNAME, + SecretsManager.KEY_CLIENT_ID, + SecretsManager.KEY_PRIVATE_KEY, + SecretsManager.KEY_CLIENT_KEY, + SecretsManager.KEY_APP_KEY, + SecretsManager.KEY_OWNER_PUBLIC_KEY, + SecretsManager.KEY_SERVER_PUBIC_KEY_ID + ))); + + /** + * Return true if the provided input is a valid base64-encoded string, + * false otherwise. + * + * @param input + * The string to check if base-64 encoded. + * + * @return + * true if the provided input is a valid base64-encoded string, + * false otherwise. + */ + private static boolean isBase64(String input) { + + try { + Base64.getDecoder().decode(input); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + /** + * Given an attributes-enabled entity, check for the presence of the + * KSM_CONFIGURATION_ATTRIBUTE attribute. If it's set, check if it's a valid + * KSM one-time token. If so, attempt to translate it to a base-64-encoded + * json KSM config blob, and set it back to the provided entity. + * If it's already a KSM config blob, validate it as config blob. If either + * validation fails, a GuacamoleException will be thrown. + * + * @param entity + * The attributes-enabled entity for which the KSM configuration + * attribute parsing/validation should be performed. + * + * @throws GuacamoleException + * If the KSM_CONFIGURATION_ATTRIBUTE is set, but fails to validate as + * either a KSM one-time-token, or a KSM base64-encoded JSON config blob. + */ + public void processAttributes(Attributes entity) throws GuacamoleException { + + // By default, if the KSM config attribute isn't being set, pass the + // provided attributes through without any changes + Map attributes = entity.getAttributes(); + + // Get the value of the KSM config attribute in the provided map + String ksmConfigValue = attributes.get( + KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE); + + // Check if the attribute is set to a non-empty value + if (ksmConfigValue != null && !ksmConfigValue.trim().isEmpty()) { + + // If it's already base64-encoded, it's a KSM configuration blob, + // so validate it immediately + if (isBase64(ksmConfigValue)) { + + // Attempt to validate the config as a base64-econded KSM config blob + try { + KsmConfig.parseKsmConfig(ksmConfigValue); + + // If it validates, the entity can be left alone - it's already valid + return; + } + + catch (GuacamoleException exception) { + + // If the parsing attempt fails, throw a translatable error for display + // on the frontend + throw new TranslatableGuacamoleClientException( + "Invalid base64-encoded JSON KSM config provided for " + + KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE + " attribute", + "CONNECTION_GROUP_ATTRIBUTES.ERROR_INVALID_KSM_CONFIG_BLOB", + exception); + } + } + + // It wasn't a valid base64-encoded string, it should be a one-time token, so + // attempt to validat it as such, and if valid, update the attribute to the + // base64 config blob generated by the token + try { + + // Create an initially empty storage to be populated using the one-time token + InMemoryStorage storage = new InMemoryStorage(); + + // Populate the in-memory storage using the one-time-token + SecretsManager.initializeStorage(storage, ksmConfigValue, null); + + // Create an options object using the values we extracted from the one-time token + SecretsManagerOptions options = new SecretsManagerOptions( + storage, null, + configurationService.getAllowUnverifiedCertificate()); + + // Attempt to fetch secrets using the options we created. This will both validate + // that the configuration works, and potentially populate missing fields that the + // initializeStorage() call did not set. + SecretsManager.getSecrets(options); + + // Create a map to store the extracted values from the KSM storage + Map configMap = new HashMap<>(); + + // Go through all the expected fields, extract from the KSM storage, + // and write to the newly created map + EXPECTED_KSM_FIELDS.forEach(configKey -> { + + // Only write the value into the new map if non-null + String value = storage.getString(configKey); + if (value != null) + configMap.put(configKey, value); + + }); + + // JSON-encode the value, and then base64 encode that to get the format + // that KSM would expect + String jsonString = objectMapper.writeValueAsString(configMap); + String base64EncodedJson = Base64.getEncoder().encodeToString( + jsonString.getBytes(StandardCharsets.UTF_8)); + + // Finally, try to parse the newly generated token as a KSM config. If this + // works, the config should be fully functional + KsmConfig.parseKsmConfig(base64EncodedJson); + + // Make a copy of the existing attributes, modifying just the value for + // KSM_CONFIGURATION_ATTRIBUTE + attributes = new HashMap<>(attributes); + attributes.put( + KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE, base64EncodedJson); + + // Set the newly updated attributes back to the original object + entity.setAttributes(attributes); + + } + + // The KSM SDK only throws raw Exceptions, so we can't be more specific + catch (Exception exception) { + + // If the parsing attempt fails, throw a translatable error for display + // on the frontend + throw new TranslatableGuacamoleClientException( + "Invalid one-time KSM token provided for " + + KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE + " attribute", + "CONNECTION_GROUP_ATTRIBUTES.ERROR_INVALID_KSM_ONE_TIME_TOKEN", + exception); + } + } + + } + + @Override + public Directory getConnectionGroupDirectory( + Directory underlyingDirectory) throws GuacamoleException { + + // A ConnectionGroup directory that will intercept add and update calls to + // validate KSM configurations, and translate one-time-tokens, if possible + return new DelegatingDirectory(underlyingDirectory) { + + @Override + public void add(ConnectionGroup connectionGroup) throws GuacamoleException { + + // Check for the KSM config attribute and translate the one-time token + // if possible before adding + processAttributes(connectionGroup); + super.add(connectionGroup); + } + + @Override + public void update(ConnectionGroup connectionGroup) throws GuacamoleException { + + // Check for the KSM config attribute and translate the one-time token + // if possible before updating + processAttributes(connectionGroup); + super.update(connectionGroup); + } + + }; + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/translations/en.json b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/translations/en.json index abda16cef..4601a13f1 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/translations/en.json +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/translations/en.json @@ -6,7 +6,10 @@ "CONNECTION_GROUP_ATTRIBUTES" : { "SECTION_HEADER_KSM_CONFIG" : "Keeper Secrets Manager", - "FIELD_HEADER_KSM_CONFIG" : "KSM Service Configuration " + "FIELD_HEADER_KSM_CONFIG" : "KSM Service Configuration ", + + "ERROR_INVALID_KSM_CONFIG_BLOB" : "The provided base64-encoded KSM configuration blob is not valid. Please ensure that you have copied the entire blob.", + "ERROR_INVALID_KSM_ONE_TIME_TOKEN" : "The provided configuration is not a valid KSM one-time token or base64-encoded configuration blob. Please ensure that you have copied the entire token value." } }