diff --git a/docs/Database-Structure.md b/docs/Database-Structure.md index 913072b96..ccd33c0d1 100644 --- a/docs/Database-Structure.md +++ b/docs/Database-Structure.md @@ -58,12 +58,13 @@ Stores configurations for the applications stored in `pa_application` table. #### Columns -| Name | Type | Info | Note | -|----------------|--------------|---------------------------------|---------------------------------------------------------------------------------------------------------------------------------| -| id | BIGINT(20) | primary key, autoincrement | Unique application configuration identifier. | -| application_id | BIGINT(20) | foreign key: pa\_application.id | Related application ID. | -| config_key | VARCHAR(255) | index | Configuration key names: `fido2_attestation_fmt_allowed`, `fido2_aaguids_allowed`, `fido2_root_ca_certs`, or `oauth2_providers` | -| config_values | TEXT | - | Configuration values serialized in JSON format. | +| Name | Type | Info | Note | +|------------------|--------------|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------| +| id | BIGINT(20) | primary key, autoincrement | Unique application configuration identifier. | +| application_id | BIGINT(20) | foreign key: pa\_application.id | Related application ID. | +| config_key | VARCHAR(255) | index | Configuration key names: `fido2_attestation_fmt_allowed`, `fido2_aaguids_allowed`, `fido2_root_ca_certs`, or `oauth2_providers` | +| config_values | TEXT | - | Configuration values serialized in JSON format. | +| encryption_mode | VARCHAR(255) | DEFAULT 'NO_ENCRYPTION' NOT NULL | Encryption of config values: `NO_ENCRYPTION` means plaintext, `AES_HMAC` for AES encryption with HMAC-based index. | diff --git a/docs/Migration-Instructions.md b/docs/Migration-Instructions.md index 0a0d8c968..d850db326 100644 --- a/docs/Migration-Instructions.md +++ b/docs/Migration-Instructions.md @@ -6,6 +6,7 @@ This page contains PowerAuth Server migration instructions. When updating across multiple versions, you need to perform all migration steps additively. +- [PowerAuth Server 1.9.0](./PowerAuth-Server-1.9.0.md) - [PowerAuth Server 1.8.0](./PowerAuth-Server-1.8.0.md) - [PowerAuth Server 1.7.0](./PowerAuth-Server-1.7.0.md) - [PowerAuth Server 1.6.0](./PowerAuth-Server-1.6.0.md) diff --git a/docs/PowerAuth-Server-1.9.0.md b/docs/PowerAuth-Server-1.9.0.md new file mode 100644 index 000000000..b52a3907c --- /dev/null +++ b/docs/PowerAuth-Server-1.9.0.md @@ -0,0 +1,19 @@ +# Migration from 1.8.x to 1.9.0 + +This guide contains instructions for migration from PowerAuth Server version `1.8.x` to version `1.9.0`. + + +## Database Changes + +For convenience, you can use liquibase for your database migration. + +For manual changes use SQL scripts: + +- [PostgreSQL script](./sql/postgresql/migration_1.8.0_1.9.0.sql) +- [Oracle script](./sql/oracle/migration_1.8.0_1.9.0.sql) +- [MSSQL script](./sql/mssql/migration_1.8.0_1.9.0.sql) + + +### Add encryption_mode Column + +A new column `encryption_mode` has been added to the `pa_application_config` table to enable encryption of configuration values. diff --git a/docs/db/changelog/changesets/powerauth-java-server/1.9.x/20240723-configuration-encryption.xml b/docs/db/changelog/changesets/powerauth-java-server/1.9.x/20240723-configuration-encryption.xml new file mode 100644 index 000000000..2f2a161ac --- /dev/null +++ b/docs/db/changelog/changesets/powerauth-java-server/1.9.x/20240723-configuration-encryption.xml @@ -0,0 +1,20 @@ + + + + + + + + + + Add encryption_mode column to pa_application_config table. + + + + + + + + \ No newline at end of file diff --git a/docs/db/changelog/changesets/powerauth-java-server/1.9.x/db.changelog-version.xml b/docs/db/changelog/changesets/powerauth-java-server/1.9.x/db.changelog-version.xml new file mode 100644 index 000000000..e62f2ffe7 --- /dev/null +++ b/docs/db/changelog/changesets/powerauth-java-server/1.9.x/db.changelog-version.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/docs/db/changelog/changesets/powerauth-java-server/db.changelog-module.xml b/docs/db/changelog/changesets/powerauth-java-server/db.changelog-module.xml index 5cdbbb36e..339c26536 100644 --- a/docs/db/changelog/changesets/powerauth-java-server/db.changelog-module.xml +++ b/docs/db/changelog/changesets/powerauth-java-server/db.changelog-module.xml @@ -17,5 +17,6 @@ + \ No newline at end of file diff --git a/docs/images/arch_db_structure.png b/docs/images/arch_db_structure.png index be3677e3a..5a867c47b 100644 Binary files a/docs/images/arch_db_structure.png and b/docs/images/arch_db_structure.png differ diff --git a/docs/sql/mssql/migration_1.8.0_1.9.0.sql b/docs/sql/mssql/migration_1.8.0_1.9.0.sql new file mode 100644 index 000000000..e9a4e3534 --- /dev/null +++ b/docs/sql/mssql/migration_1.8.0_1.9.0.sql @@ -0,0 +1,4 @@ +-- Changeset powerauth-java-server/1.9.x/20240723-configuration-encryption.xml::1::Lubos Racansky +-- Add encryption_mode column to pa_application_config table. +ALTER TABLE pa_application_config ADD encryption_mode varchar(255) CONSTRAINT DF_pa_application_config_encryption_mode DEFAULT 'NO_ENCRYPTION' NOT NULL; +GO diff --git a/docs/sql/oracle/migration_1.8.0_1.9.0.sql b/docs/sql/oracle/migration_1.8.0_1.9.0.sql new file mode 100644 index 000000000..ed98a7f1a --- /dev/null +++ b/docs/sql/oracle/migration_1.8.0_1.9.0.sql @@ -0,0 +1,3 @@ +-- Changeset powerauth-java-server/1.9.x/20240723-configuration-encryption.xml::1::Lubos Racansky +-- Add encryption_mode column to pa_application_config table. +ALTER TABLE pa_application_config ADD encryption_mode VARCHAR2(255) DEFAULT 'NO_ENCRYPTION' NOT NULL; diff --git a/docs/sql/postgresql/migration_1.8.0_1.9.0.sql b/docs/sql/postgresql/migration_1.8.0_1.9.0.sql new file mode 100644 index 000000000..95a3ac0b3 --- /dev/null +++ b/docs/sql/postgresql/migration_1.8.0_1.9.0.sql @@ -0,0 +1,3 @@ +-- Changeset powerauth-java-server/1.9.x/20240723-configuration-encryption.xml::1::Lubos Racansky +-- Add encryption_mode column to pa_application_config table. +ALTER TABLE pa_application_config ADD encryption_mode VARCHAR(255) DEFAULT 'NO_ENCRYPTION' NOT NULL; diff --git a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/CreateApplicationConfigRequest.java b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/CreateApplicationConfigRequest.java index 03276356e..5b3df0b2b 100644 --- a/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/CreateApplicationConfigRequest.java +++ b/powerauth-client-model/src/main/java/com/wultra/security/powerauth/client/model/request/CreateApplicationConfigRequest.java @@ -19,7 +19,9 @@ package com.wultra.security.powerauth.client.model.request; import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; import java.util.ArrayList; import java.util.List; @@ -30,12 +32,16 @@ * @author Roman Strobl, roman.strobl@wultra.com */ @Data +@AllArgsConstructor +@NoArgsConstructor public class CreateApplicationConfigRequest { @NotBlank private String applicationId; + @NotBlank private String key; + private List values = new ArrayList<>(); } diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/converter/RecoveryPrivateKeyConverter.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/converter/RecoveryPrivateKeyConverter.java index ee799890c..af23245e0 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/converter/RecoveryPrivateKeyConverter.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/converter/RecoveryPrivateKeyConverter.java @@ -17,17 +17,16 @@ */ package io.getlime.security.powerauth.app.server.converter; -import java.util.Base64; -import java.util.List; - -import org.springframework.stereotype.Component; - import io.getlime.security.powerauth.app.server.database.model.RecoveryPrivateKey; -import io.getlime.security.powerauth.app.server.service.encryption.Encryptable; +import io.getlime.security.powerauth.app.server.service.encryption.EncryptableData; import io.getlime.security.powerauth.app.server.service.encryption.EncryptionService; import io.getlime.security.powerauth.app.server.service.exceptions.GenericServiceException; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Base64; +import java.util.List; /** * Converter for recovery postcard private key which handles key encryption and decryption in case it is configured. @@ -67,8 +66,8 @@ public String fromDBValue(final RecoveryPrivateKey recoveryPrivateKey, long appl * @throws GenericServiceException Thrown when recovery postcard private key encryption fails. */ public RecoveryPrivateKey toDBValue(byte[] recoveryPrivateKey, long applicationRid) throws GenericServiceException { - final Encryptable encryptable = encryptionService.encrypt(recoveryPrivateKey, createSecretKeyDerivationInput(applicationRid)); - return new RecoveryPrivateKey(encryptable.getEncryptionMode(), convert(encryptable.getEncryptedData())); + final EncryptableData encryptable = encryptionService.encrypt(recoveryPrivateKey, createSecretKeyDerivationInput(applicationRid)); + return new RecoveryPrivateKey(encryptable.encryptionMode(), convert(encryptable.encryptedData())); } private static String convert(final byte[] source) { diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/converter/RecoveryPukConverter.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/converter/RecoveryPukConverter.java index 8dc6cbcdb..0d6ba3484 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/converter/RecoveryPukConverter.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/converter/RecoveryPukConverter.java @@ -17,19 +17,15 @@ */ package io.getlime.security.powerauth.app.server.converter; -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.List; - -import org.springframework.stereotype.Component; - import io.getlime.security.powerauth.app.server.database.model.RecoveryPuk; -import io.getlime.security.powerauth.app.server.database.model.enumeration.EncryptionMode; -import io.getlime.security.powerauth.app.server.service.encryption.Encryptable; +import io.getlime.security.powerauth.app.server.service.encryption.EncryptableString; import io.getlime.security.powerauth.app.server.service.encryption.EncryptionService; import io.getlime.security.powerauth.app.server.service.exceptions.GenericServiceException; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; /** * Converter for recovery PUK which handles record encryption and decryption in case it is configured. @@ -56,9 +52,7 @@ public class RecoveryPukConverter { * @throws GenericServiceException In case recovery PUK hash decryption fails. */ public String fromDBValue(final RecoveryPuk recoveryPuk, final long applicationRid, final String userId, final String recoveryCode, final long pukIndex) throws GenericServiceException { - final byte[] data = convert(recoveryPuk); - final byte[] decrypted = encryptionService.decrypt(data, recoveryPuk.encryptionMode(), createSecretKeyDerivationInput(applicationRid, userId, recoveryCode, pukIndex)); - return convert(decrypted); + return encryptionService.decrypt(recoveryPuk.pukHash(), recoveryPuk.encryptionMode(), createSecretKeyDerivationInput(applicationRid, userId, recoveryCode, pukIndex)); } /** @@ -75,33 +69,8 @@ public String fromDBValue(final RecoveryPuk recoveryPuk, final long applicationR * @throws GenericServiceException Thrown when server private key encryption fails. */ public RecoveryPuk toDBValue(final String pukHash, final long applicationRid, final String userId, final String recoveryCode, final long pukIndex) throws GenericServiceException { - final byte[] data = convert(pukHash); - final Encryptable encryptable = encryptionService.encrypt(data, createSecretKeyDerivationInput(applicationRid, userId, recoveryCode, pukIndex)); - return new RecoveryPuk(encryptable.getEncryptionMode(), convert(encryptable)); - } - - private static byte[] convert(final RecoveryPuk source) { - if (source.encryptionMode() == EncryptionMode.NO_ENCRYPTION) { - return source.pukHash().getBytes(StandardCharsets.UTF_8); - } else { - return Base64.getDecoder().decode(source.pukHash()); - } - } - - private static byte[] convert(final String source) { - return source.getBytes(StandardCharsets.UTF_8); - } - - private static String convert(final Encryptable source) { - if (source.getEncryptionMode() == EncryptionMode.NO_ENCRYPTION) { - return convert(source.getEncryptedData()); - } else { - return Base64.getEncoder().encodeToString(source.getEncryptedData()); - } - } - - private static String convert(final byte[] source) { - return new String(source, StandardCharsets.UTF_8); + final EncryptableString encryptable = encryptionService.encrypt(pukHash, createSecretKeyDerivationInput(applicationRid, userId, recoveryCode, pukIndex)); + return new RecoveryPuk(encryptable.encryptionMode(), encryptable.encryptedData()); } private static List createSecretKeyDerivationInput(final long applicationRid, final String userId, final String recoveryCode, final long pukIndex) { diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/converter/ServerPrivateKeyConverter.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/converter/ServerPrivateKeyConverter.java index 3aef4e2cf..27a46b97e 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/converter/ServerPrivateKeyConverter.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/converter/ServerPrivateKeyConverter.java @@ -17,17 +17,16 @@ */ package io.getlime.security.powerauth.app.server.converter; -import java.util.Base64; -import java.util.List; - -import org.springframework.stereotype.Component; - import io.getlime.security.powerauth.app.server.database.model.ServerPrivateKey; -import io.getlime.security.powerauth.app.server.service.encryption.Encryptable; +import io.getlime.security.powerauth.app.server.service.encryption.EncryptableData; import io.getlime.security.powerauth.app.server.service.encryption.EncryptionService; import io.getlime.security.powerauth.app.server.service.exceptions.GenericServiceException; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Base64; +import java.util.List; /** * Converter for server private key which handles key encryption and decryption in case it is configured. @@ -69,8 +68,8 @@ public String fromDBValue(final ServerPrivateKey serverPrivateKey, final String * @throws GenericServiceException Thrown when server private key encryption fails. */ public ServerPrivateKey toDBValue(final byte[] serverPrivateKey, final String userId, final String activationId) throws GenericServiceException { - final Encryptable encryptable = encryptionService.encrypt(serverPrivateKey, createSecretKeyDerivationInput(userId, activationId)); - return new ServerPrivateKey(encryptable.getEncryptionMode(), convert(encryptable.getEncryptedData())); + final EncryptableData encryptable = encryptionService.encrypt(serverPrivateKey, createSecretKeyDerivationInput(userId, activationId)); + return new ServerPrivateKey(encryptable.encryptionMode(), convert(encryptable.encryptedData())); } private static String convert(final byte[] source) { diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/entity/ApplicationConfigEntity.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/entity/ApplicationConfigEntity.java index 7a5db94e1..c53f282fa 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/entity/ApplicationConfigEntity.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/database/model/entity/ApplicationConfigEntity.java @@ -17,7 +17,7 @@ */ package io.getlime.security.powerauth.app.server.database.model.entity; -import io.getlime.security.powerauth.app.server.database.model.converter.ListToJsonConverter; +import io.getlime.security.powerauth.app.server.database.model.enumeration.EncryptionMode; import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; @@ -25,8 +25,6 @@ import java.io.Serial; import java.io.Serializable; -import java.util.ArrayList; -import java.util.List; import java.util.Objects; /** @@ -57,8 +55,11 @@ public class ApplicationConfigEntity implements Serializable { private String key; @Column(name = "config_values", columnDefinition = "CLOB") - @Convert(converter = ListToJsonConverter.class) - private List values = new ArrayList<>(); + private String values = "[]"; + + @Enumerated(EnumType.STRING) + @Column(name = "encryption_mode", nullable = false, columnDefinition = "varchar(255) default 'NO_ENCRYPTION'") + private EncryptionMode encryptionMode; @Override public boolean equals(Object o) { @@ -83,6 +84,7 @@ public String toString() { ", appId='" + application.getId() + '\'' + ", key=" + key + ", values=" + values + + ", encryptionMode=" + encryptionMode + '}'; } } diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/ApplicationConfigServiceBehavior.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/ApplicationConfigServiceBehavior.java index 42056f4d1..a79dafedd 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/ApplicationConfigServiceBehavior.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/behavior/tasks/ApplicationConfigServiceBehavior.java @@ -32,8 +32,9 @@ import io.getlime.security.powerauth.app.server.service.exceptions.GenericServiceException; import io.getlime.security.powerauth.app.server.service.i18n.LocalizationProvider; import io.getlime.security.powerauth.app.server.service.model.ServiceError; +import io.getlime.security.powerauth.app.server.service.persistence.ApplicationConfigService; +import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -51,6 +52,7 @@ */ @Service @Slf4j +@AllArgsConstructor public class ApplicationConfigServiceBehavior { private static final String CONFIG_KEY_OAUTH2_PROVIDERS = "oauth2_providers"; @@ -60,17 +62,7 @@ public class ApplicationConfigServiceBehavior { private final RepositoryCatalogue repositoryCatalogue; private final LocalizationProvider localizationProvider; - - /** - * Behaviour class constructor. - * @param repositoryCatalogue Repository catalogue. - * @param localizationProvider Localization provider. - */ - @Autowired - public ApplicationConfigServiceBehavior(final RepositoryCatalogue repositoryCatalogue, final LocalizationProvider localizationProvider) { - this.repositoryCatalogue = repositoryCatalogue; - this.localizationProvider = localizationProvider; - } + private final ApplicationConfigService applicationConfigService; /** * Get application configuration. @@ -87,14 +79,14 @@ public GetApplicationConfigResponse getApplicationConfig(final GetApplicationCon // Rollback is not required, error occurs before writing to database throw localizationProvider.buildExceptionForCode(ServiceError.INVALID_REQUEST); } - final List applicationConfigs = repositoryCatalogue.getApplicationConfigRepository().findByApplicationId(applicationId); + final List applicationConfigs = applicationConfigService.findByApplicationId(applicationId); final GetApplicationConfigResponse response = new GetApplicationConfigResponse(); response.setApplicationId(applicationId); final List responseConfigs = new ArrayList<>(); applicationConfigs.forEach(config -> { final ApplicationConfigurationItem item = new ApplicationConfigurationItem(); - item.setKey(config.getKey()); - item.setValues(config.getValues()); + item.setKey(config.key()); + item.setValues(config.values()); responseConfigs.add(item); }); response.setApplicationConfigs(responseConfigs); @@ -124,28 +116,21 @@ public CreateApplicationConfigResponse createApplicationConfig(final CreateAppli } validateConfigKey(key); final ApplicationRepository appRepository = repositoryCatalogue.getApplicationRepository(); - final Optional appOptional = appRepository.findById(applicationId); - if (appOptional.isEmpty()) { + final ApplicationEntity application = appRepository.findById(applicationId).orElseThrow(() -> { logger.info("Application not found, application ID: {}", applicationId); // Rollback is not required, error occurs before writing to database - throw localizationProvider.buildExceptionForCode(ServiceError.INVALID_APPLICATION); - } - final ApplicationEntity appEntity = appOptional.get(); - final ApplicationConfigRepository configRepository = repositoryCatalogue.getApplicationConfigRepository(); - final List configs = configRepository.findByApplicationId(applicationId); - final Optional matchedConfig = configs.stream() - .filter(config -> config.getKey().equals(key)) - .findFirst(); - matchedConfig.ifPresentOrElse(config -> { - config.setValues(values); - configRepository.save(config); - }, () -> { - final ApplicationConfigEntity config = new ApplicationConfigEntity(); - config.setApplication(appEntity); - config.setKey(key); - config.setValues(values); - configRepository.save(config); + return localizationProvider.buildExceptionForCode(ServiceError.INVALID_APPLICATION); }); + final Optional matchedConfig = applicationConfigService.findByApplicationId(applicationId).stream() + .filter(config -> config.key().equals(key)) + .findFirst(); + if (matchedConfig.isPresent()) { + final ApplicationConfigService.ApplicationConfig existing = matchedConfig.get(); + applicationConfigService.createOrUpdate(new ApplicationConfigService.ApplicationConfig(existing.id(), existing.application(), existing.key(), values)); + } else { + applicationConfigService.createOrUpdate(new ApplicationConfigService.ApplicationConfig(null, application, key, values)); + } + final CreateApplicationConfigResponse response = new CreateApplicationConfigResponse(); response.setApplicationId(applicationId); response.setKey(key); diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/encryption/EncryptableData.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/encryption/EncryptableData.java new file mode 100644 index 000000000..691f4189b --- /dev/null +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/encryption/EncryptableData.java @@ -0,0 +1,57 @@ +/* + * PowerAuth Server and related software components + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.app.server.service.encryption; + +import io.getlime.security.powerauth.app.server.database.model.enumeration.EncryptionMode; +import org.apache.commons.lang3.ArrayUtils; + +import java.util.Arrays; +import java.util.Objects; + +/** + * A wrapper for data encryption, keeping both the mode and the data. + * + * @param encryptionMode Encryption mode. Determine format of {@link #encryptedData()}. + * @param encryptedData Data. May be plain or encrypted. Depends on {@link #encryptionMode()}. + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +public record EncryptableData(EncryptionMode encryptionMode, byte[] encryptedData) { + @Override + public String toString() { + return "EncryptableRecord{" + + "encryptionMode=" + encryptionMode + + ", encryptedDataLength=" + ArrayUtils.getLength(encryptedData) + + '}'; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof final EncryptableData that)) { + return false; + } + return Objects.deepEquals(encryptedData, that.encryptedData) && encryptionMode == that.encryptionMode; + } + + @Override + public int hashCode() { + return Objects.hash(encryptionMode, Arrays.hashCode(encryptedData)); + } +} diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/encryption/Encryptable.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/encryption/EncryptableString.java similarity index 66% rename from powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/encryption/Encryptable.java rename to powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/encryption/EncryptableString.java index 3b4ed6822..a06b3f92d 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/encryption/Encryptable.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/encryption/EncryptableString.java @@ -20,27 +20,12 @@ import io.getlime.security.powerauth.app.server.database.model.enumeration.EncryptionMode; /** - * A generic wrapper for encryption, keeping both the mode and the data. + * A wrapper for String encryption, keeping both the mode and the data. * + * @param encryptionMode Encryption mode. Determine format of {@link #encryptedData()}. + * @param encryptedData Data. May be plain or encrypted. Depends on {@link #encryptionMode()}. * @author Lubos Racansky, lubos.racansky@wultra.com */ -public interface Encryptable { +public record EncryptableString(EncryptionMode encryptionMode, String encryptedData) { - /** - * Return encryption mode. - *

- * Determine format of {@link #getEncryptedData()}. - * - * @return encryption mode - */ - EncryptionMode getEncryptionMode(); - - /** - * Return the data. - *

- * May be plain or encrypted. Depends on {@link #getEncryptionMode()}. - * - * @return encrypted or plain data - */ - byte[] getEncryptedData(); } diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/encryption/EncryptionService.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/encryption/EncryptionService.java index e9dcb925e..6f7411aae 100644 --- a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/encryption/EncryptionService.java +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/encryption/EncryptionService.java @@ -17,20 +17,6 @@ */ package io.getlime.security.powerauth.app.server.service.encryption; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.security.InvalidKeyException; -import java.util.Arrays; -import java.util.Base64; -import java.util.List; -import java.util.Objects; - -import javax.crypto.SecretKey; - -import org.apache.commons.lang3.ArrayUtils; -import org.springframework.stereotype.Service; - import io.getlime.security.powerauth.app.server.configuration.PowerAuthServiceConfiguration; import io.getlime.security.powerauth.app.server.database.model.enumeration.EncryptionMode; import io.getlime.security.powerauth.app.server.service.exceptions.GenericServiceException; @@ -43,6 +29,16 @@ import io.getlime.security.powerauth.crypto.lib.util.KeyConvertor; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; /** * Service for encryption and decryption database data. @@ -62,6 +58,23 @@ public class EncryptionService { private final AESEncryptionUtils aesEncryptionUtils = new AESEncryptionUtils(); private final KeyConvertor keyConvertor = new KeyConvertor(); + + /** + * Decrypt the given string. + * + * @param dataString String to decrypt. + * @param encryptionMode Encryption mode. + * @param secretKeyDerivationInput Values used for derivation of secret key. + * @return Decrypted value. + * @see #decrypt(byte[], EncryptionMode, List) if you want to encrypt binary data. + * @throws GenericServiceException In case decryption fails. + */ + public String decrypt(final String dataString, final EncryptionMode encryptionMode, final List secretKeyDerivationInput) throws GenericServiceException { + final byte[] dataBytes = convert(dataString, encryptionMode); + final byte[] decrypted = decrypt(dataBytes, encryptionMode, secretKeyDerivationInput); + return new String(decrypted, StandardCharsets.UTF_8); + } + /** * Decrypt the given data. * @@ -69,6 +82,7 @@ public class EncryptionService { * @param encryptionMode Encryption mode. * @param secretKeyDerivationInput Values used for derivation of secret key. * @return Decrypted value. + * @see #decrypt(String, EncryptionMode, List) if you want to encrypt string data. * @throws GenericServiceException In case decryption fails. */ public byte[] decrypt(final byte[] data, final EncryptionMode encryptionMode, final List secretKeyDerivationInput) throws GenericServiceException { @@ -127,20 +141,36 @@ public byte[] decrypt(final byte[] data, final EncryptionMode encryptionMode, fi } } + /** + * Encrypt the given string. + * + * @param data String to encrypt. + * @param secretKeyDerivations Values used for derivation of secret key. + * @return Encryptable composite data. + * @see #encrypt(byte[], List) if you want to encrypt binary data. + * @throws GenericServiceException Thrown when encryption fails. + */ + public EncryptableString encrypt(final String data, final List secretKeyDerivations) throws GenericServiceException { + final byte[] dataBytes = data.getBytes(StandardCharsets.UTF_8); + final EncryptableData result = encrypt(dataBytes, secretKeyDerivations); + return new EncryptableString(result.encryptionMode(), convert(result)); + } + /** * Encrypt the given data. * * @param data Data to encrypt. * @param secretKeyDerivations Values used for derivation of secret key. * @return Encryptable composite data. + * @see #encrypt(String, List) if you want to encrypt binary data. * @throws GenericServiceException Thrown when encryption fails. */ - public Encryptable encrypt(final byte[] data, final List secretKeyDerivations) throws GenericServiceException { + public EncryptableData encrypt(final byte[] data, final List secretKeyDerivations) throws GenericServiceException { final String masterDbEncryptionKeyBase64 = powerAuthServiceConfiguration.getMasterDbEncryptionKey(); // In case master DB encryption key does not exist, do not encrypt the value if (masterDbEncryptionKeyBase64 == null || masterDbEncryptionKeyBase64.isEmpty()) { - return new EncryptableRecord(EncryptionMode.NO_ENCRYPTION, data); + return new EncryptableData(EncryptionMode.NO_ENCRYPTION, data); } try { @@ -162,7 +192,7 @@ public Encryptable encrypt(final byte[] data, final List secretKeyDeriva baos.write(encrypted); final byte[] encryptedData = baos.toByteArray(); - return new EncryptableRecord(EncryptionMode.AES_HMAC, encryptedData); + return new EncryptableData(EncryptionMode.AES_HMAC, encryptedData); } catch (InvalidKeyException ex) { logger.error(ex.getMessage(), ex); throw localizationProvider.buildExceptionForCode(ServiceError.INVALID_KEY_FORMAT); @@ -195,39 +225,19 @@ private SecretKey deriveSecretKey(SecretKey masterDbEncryptionKey, final List - applicationConfig.getValues().stream() + applicationConfig.values().stream() .filter(String.class::isInstance) .map(String.class::cast) .map(certPem -> { diff --git a/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/persistence/ApplicationConfigService.java b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/persistence/ApplicationConfigService.java new file mode 100644 index 000000000..4ab0e7716 --- /dev/null +++ b/powerauth-java-server/src/main/java/io/getlime/security/powerauth/app/server/service/persistence/ApplicationConfigService.java @@ -0,0 +1,122 @@ +/* + * PowerAuth Server and related software components + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.app.server.service.persistence; + +import io.getlime.security.powerauth.app.server.database.model.converter.ListToJsonConverter; +import io.getlime.security.powerauth.app.server.database.model.entity.ApplicationConfigEntity; +import io.getlime.security.powerauth.app.server.database.model.entity.ApplicationEntity; +import io.getlime.security.powerauth.app.server.database.repository.ApplicationConfigRepository; +import io.getlime.security.powerauth.app.server.service.encryption.EncryptableString; +import io.getlime.security.powerauth.app.server.service.encryption.EncryptionService; +import io.getlime.security.powerauth.app.server.service.exceptions.GenericServiceException; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * Service for application configuration. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Service +@Slf4j +@AllArgsConstructor +public class ApplicationConfigService { + + private final ApplicationConfigRepository applicationConfigRepository; + private final ListToJsonConverter listToJsonConverter; + private final EncryptionService encryptionService; + + /** + * Find application configuration by application ID. + * + * @param applicationId Application ID. + * @param key Config key. + * @return Application config or empty. + */ + @Transactional(readOnly = true) + public Optional findByApplicationIdAndKey(final String applicationId, final String key) { + return applicationConfigRepository.findByApplicationIdAndKey(applicationId, key) + .map(this::convert); + } + + /** + * Find application configurations by application ID. + * + * @param applicationId Application ID. + * @return List of application config entities. + */ + @Transactional(readOnly = true) + public List findByApplicationId(final String applicationId) { + return applicationConfigRepository.findByApplicationId(applicationId).stream() + .map(this::convert) + .toList(); + } + + @Transactional + public void createOrUpdate(final ApplicationConfig source) throws GenericServiceException { + applicationConfigRepository.save(convert(source)); + } + + private ApplicationConfigEntity convert(final ApplicationConfig source) throws GenericServiceException { + final ApplicationConfigEntity entity = new ApplicationConfigEntity(); + entity.setRid(source.id()); + entity.setApplication(source.application()); + entity.setKey(source.key()); + + final String value = listToJsonConverter.convertToDatabaseColumn(source.values()); + final EncryptableString encryptable = encryptionService.encrypt(value, secretKeyDerivationInput(entity)); + entity.setValues(encryptable.encryptedData()); + entity.setEncryptionMode(encryptable.encryptionMode()); + + return entity; + } + + private ApplicationConfig convert(ApplicationConfigEntity source) { + try { + final String decrypted = encryptionService.decrypt(source.getValues(), source.getEncryptionMode(), secretKeyDerivationInput(source)); + final List values = listToJsonConverter.convertToEntityAttribute(decrypted); + return new ApplicationConfig(source.getRid(), source.getApplication(), source.getKey(), values); + } catch (GenericServiceException e) { + logger.warn("Problem to decrypt config ID: {}", source.getRid()); + return null; + } + } + + private static List secretKeyDerivationInput(final ApplicationConfigEntity source) { + return List.of(source.getApplication().getId()); + } + + /** + * Decrypted wrapper of {@link ApplicationConfigEntity}. + * + * @param id + * @param key + * @param application + * @param values + */ + public record ApplicationConfig( + Long id, + ApplicationEntity application, + String key, + List values) {} +} diff --git a/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/RecoveryPrivateKeyConverterTest.java b/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/RecoveryPrivateKeyConverterTest.java index 5927b2455..bb008a0b9 100644 --- a/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/RecoveryPrivateKeyConverterTest.java +++ b/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/RecoveryPrivateKeyConverterTest.java @@ -17,21 +17,18 @@ */ package io.getlime.security.powerauth.app.server; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.Base64; - +import io.getlime.security.powerauth.app.server.converter.RecoveryPrivateKeyConverter; +import io.getlime.security.powerauth.app.server.database.model.RecoveryPrivateKey; +import io.getlime.security.powerauth.app.server.database.model.enumeration.EncryptionMode; +import io.getlime.security.powerauth.app.server.service.exceptions.GenericServiceException; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -import io.getlime.security.powerauth.app.server.converter.RecoveryPrivateKeyConverter; -import io.getlime.security.powerauth.app.server.database.model.RecoveryPrivateKey; -import io.getlime.security.powerauth.app.server.database.model.enumeration.EncryptionMode; -import io.getlime.security.powerauth.app.server.service.exceptions.GenericServiceException; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; /** * Tests for {@link RecoveryPrivateKeyConverter}. diff --git a/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/service/persistence/ApplicationConfigServiceTest.java b/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/service/persistence/ApplicationConfigServiceTest.java new file mode 100644 index 000000000..20b4d1bce --- /dev/null +++ b/powerauth-java-server/src/test/java/io/getlime/security/powerauth/app/server/service/persistence/ApplicationConfigServiceTest.java @@ -0,0 +1,116 @@ +/* + * PowerAuth Server and related software components + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.app.server.service.persistence; + +import io.getlime.security.powerauth.app.server.database.model.entity.ApplicationConfigEntity; +import io.getlime.security.powerauth.app.server.database.model.entity.ApplicationEntity; +import io.getlime.security.powerauth.app.server.database.model.enumeration.EncryptionMode; +import io.getlime.security.powerauth.app.server.database.repository.ApplicationConfigRepository; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test for {@link ApplicationConfigService}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +class ApplicationConfigServiceTest { + + @SpringBootTest + @ActiveProfiles("test") + @Nested + @Sql("ApplicationConfigServiceTest.sql") + @Transactional + class Encrypted { + + @Autowired + private ApplicationConfigService tested; + + @Autowired + private ApplicationConfigRepository repository; + + @Autowired + private EntityManager entityManager; + + @Test + void testCreate() throws Exception { + final ApplicationEntity application = entityManager.find(ApplicationEntity.class, 21L); + final ApplicationConfigService.ApplicationConfig source + = new ApplicationConfigService.ApplicationConfig(null, application, "oauth2_providers", List.of("client_secret")); + + tested.createOrUpdate(source); + + final Optional result = tested.findByApplicationIdAndKey("PA_Tests", "oauth2_providers"); + assertTrue(result.isPresent()); + assertEquals(List.of("client_secret"), result.get().values()); + + final Optional entity = repository.findByApplicationIdAndKey("PA_Tests", "oauth2_providers"); + assertTrue(entity.isPresent()); + assertEquals(EncryptionMode.AES_HMAC, entity.get().getEncryptionMode()); + assertFalse(entity.get().getValues().contains("client_secret")); + } + } + + @SpringBootTest + @ActiveProfiles("test") + @Nested + @TestPropertySource(properties = "powerauth.server.db.master.encryption.key=") + @Sql("ApplicationConfigServiceTest.sql") + @Transactional + class Plain { + + @Autowired + private ApplicationConfigService tested; + + @Autowired + private ApplicationConfigRepository repository; + + @Autowired + private EntityManager entityManager; + + @Test + void testCreate() throws Exception { + final ApplicationEntity application = entityManager.find(ApplicationEntity.class, 21L); + final ApplicationConfigService.ApplicationConfig source + = new ApplicationConfigService.ApplicationConfig(null, application, "oauth2_providers", List.of("client_secret")); + + tested.createOrUpdate(source); + + final Optional result = tested.findByApplicationIdAndKey("PA_Tests", "oauth2_providers"); + assertTrue(result.isPresent()); + assertEquals(List.of("client_secret"), result.get().values()); + + final Optional entity = repository.findByApplicationIdAndKey("PA_Tests", "oauth2_providers"); + assertTrue(entity.isPresent()); + assertEquals(EncryptionMode.NO_ENCRYPTION, entity.get().getEncryptionMode()); + assertEquals("[ \"client_secret\" ]", entity.get().getValues()); + } + } +} diff --git a/powerauth-java-server/src/test/resources/io/getlime/security/powerauth/app/server/service/persistence/ApplicationConfigServiceTest.sql b/powerauth-java-server/src/test/resources/io/getlime/security/powerauth/app/server/service/persistence/ApplicationConfigServiceTest.sql new file mode 100644 index 000000000..15b974d73 --- /dev/null +++ b/powerauth-java-server/src/test/resources/io/getlime/security/powerauth/app/server/service/persistence/ApplicationConfigServiceTest.sql @@ -0,0 +1,2 @@ +INSERT INTO pa_application (id, name) VALUES + (21, 'PA_Tests');