파란하늘의 지식창고
반응형

Spring Boot 프로젝트에서 암복호화 사용에 대해

https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.external-config.encrypting

Spring Boot를 사용할 경우 properties의 값의 암호화를 위한 기본 지원을 제공하지 않는다.
다만 Spring Environment에 포함된 값을 수정하는데 필요한 hook point를 제공한다.
EnvironmentPostPropcessor interface를 구현하여 application이 start 하기 전에 environment를 조작할 수 있다.

따라서 EnvironmentPostProcessor를 통해 properties의 값들에 대해 암호화 여부를 확인하고 복호화 처리를 하는 코드를 작업해야 한다.

이 코드를 작성하는 게 복잡하다고 생각하기 때문에 보통 jasypt-spring-boot 같은 외부 라이브러리를 사용하게 된다.

jasypt-spring-boot도 좋은 라이브러리이긴 하지만 jasypt가 2019년 5월 25일 릴리즈 된 1.9.3 버전 이후 4년이 지난 현재까지 다음 릴리즈가 나오고 있지 않고 있고 이를 사용한 jasypt-spring-boot의 경우 2022년 12월 15일 3.0.5가 마지막 버전이다.

http://www.jasypt.org/

https://github.com/ulisesbocchio/jasypt-spring-boot

외부 라이브러리를 사용하는 것이 편할 때도 있긴 하지만 프레임워크 판올림 시 변경된 버전을 지원하지 않으면 이후 교체를 검토해야 하거나 혹은 해당 라이브러리가 변경된 버전을 지원할 때까지 기다려야 하기 때문에 병목점이 될 수도 있다.

이참에 암복호화 처리를 구현해 보았다.
암호화 방식이 변경되어도 유연하게 변경하면서 기존 암호화 처리에 대한 유지도 가능하도록 구현하는데 중점을 두었고 해당 코드는 Spring SecurityDelegatingPasswordEncoder 의 사용 방식을 그대로 차용하였다.

spring-security-crypto 소개

https://docs.spring.io/spring-security/reference/features/integrations/cryptography.html

Spring은 암호화를 위해 spring-security-crypto 를 제공한다.
spring-security 에 속한 jar처럼 보이지만 spring-security-cryptospring-security 에 의존성 없이 사용할 수 있는 독립된 라이브러리이다.

다음과 같은 기능을 제공한다.

  • Encryptor : 양 방향 암호화를 위해 사용 (ByteEncryptor, TextEncryptor)
  • PasswordEncoder : 단 방향 암호화를 위해 사용
  • Key Generator : 키 생성 (ByteKeyGenerator, StringKeyGenerator)

암호화에 대한 interface를 제안하고 이를 사용한 기본적인 구현만 제공하는 라이브러리이며 spring-cloud-config-server나 spring-securit에서는 이를 확장한 spring-security-rsa 를 사용하고 있다.

properties의 값을 암호화하는 경우 양방향 암호화를 해야 하기 때문에 TextEncryptor를 구현하여 사용하면 된다.
(굳이 spring-security-crypto를 사용하지 않더라도 상관은 없다. 선택의 문제이다.)

Spring Cloud Config Server의 암호화 방식

https://docs.spring.io/spring-cloud-config/docs/current/reference/html/#_encryption_and_decryption

Spring Cloud 프로젝트의 경우 암호화 처리를 위한 간단한 방식을 제공한다.

properties에 설정된 값의 prefix에 {cipher} 라는 값이 있으면 그 이후의 값은 암호화된 값으로 판단하고 복호화 처리를 한다.

예를 들면 아래의 password의 값이 그러한 경우이다.

spring.datasource.username=dbuser
spring.datasource.password={cipher}FKSAJDFGYOS8F7GLHAKERGFHLSAJ

값의 암호화를 위해 간단하게 암호화/복호화에 대한 요청을 처리하는 /encrypt , /decrypt endpoint를 제공하고 있다.
이 호출을 통해 값의 암호화/복호화를 확인할 수 있다.

$ curl localhost:8888/encrypt -s -d mysecret
682bc583f4641835fa2db009355293665d2647dade3375c0ee201de2a49f7bda

이렇게 암호화된 값을 properties에 설정할 때 {cipher} 접두어를 추가하여 설정하면 된다.

또한 /\*/{application}/{profiles} 요청 경로에 대해 application , profile 별 암호화 처리에 사용하는 encryptor 를 다르게 사용할 수도 있다.
(TextEncryptorLocator bean을 사용하면 된다)

이렇게 암호화된 값을 사용하기 위해서는 KeyPropertiesKeyStore 설정을 하여야 한다.

인증서를 생성하고 해당 인증서를 사용할 수 있도록 대략 아래와 같이 설정을 한다.

encrypt:
  keyStore:
    location: classpath:/server.jks
    password: letmein
    alias: mytestkey
    secret: changeme

(인증서를 생성하거나 사용하기 위한 방법은 다양하며 테스트를 위해 직접 만들어 사용하는 경우 java의 keytool 사용을 찾아보면 된다.)

Spring Security의 PasswordEncoder 사용

Spring Security 의 경우 유저의 계정 비밀번호를 저장하고 단방향으로 확인하기 위해 PasswordEncoder 를 사용한다.

Spring Cloud Config Server 가 저장된 값의 {cipher} 접두어로 암호화 여부를 확인하고 /\*/{application}/{profiles} 요청 경로에 대해 application , profileencryptor 처리를 지원하는 것과 다르게 Spring Security 의 경우 접두어의 예약어를 통해 암호화의 방식을 확인할 수 있다.

이를 위해 spring-security-cryptoDelegatingPasswordEncoder 를 사용하여 여러 PasswordEncoder 를 사용하고 PasswordEncoderFactories 로 저장할 PasswordEncoderencodingId를 지정하여 제공하고 있다.

spring-security-crypto 6.1.0 버전 기준으로 PasswordEncoderFactories 는 다음과 같은 여러 passwordEncoder 를 사용하는 DelegatingPasswordEncorder 를 제공하고 있다.

@SuppressWarnings("deprecation")
public static PasswordEncoder createDelegatingPasswordEncoder() {
    String encodingId = "bcrypt";
    Map<String, PasswordEncoder> encoders = new HashMap<>();
    encoders.put(encodingId, new BCryptPasswordEncoder());
    encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
    encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
    encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
    encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
    encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
    encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
    encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1());
    encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
    encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
    encoders.put("SHA-256",
            new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
    encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
    encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
    encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
    return new DelegatingPasswordEncoder(encodingId, encoders);
}

유저 정보 저장 시 입력한 비밀번호를 DelegatingPasswordEncoder 를 사용하여 암호화 처리하고 별도 설정을 하지 않은 경우 기본 값인 bcrypt를 사용하여 저장한다.

예를 들어 아래와 같이 유저 정보를 저장한 경우

@Test
void createUser() {
    UserDetails user = User.builder()
            .username("user")
            .passwordEncoder(passwordEncoder::encode)
            .password("pass")
            .roles("USER")
            .build();
    userDetailsManager.createUser(user);
    assertThat(user).isNotNull();
}

DB에는 password의 값이 아래와 같이 {bcrypt} 접두어가 붙은 형태로 암호화되어 저장된다.

{bcrypt}$2a$10$rlinfm92jQOT0quI3nMvEuAl0OJsxGZHbow9MijQ1j5WW33rlLHuG

이와 같이 제공되는 여러 passwordEncoder 를 사용하여 저장하면 각 암호화된 값은 {bcrypt} , {MD4} , {MD5} , {SHA-256} 등의 접두어를 통해 어떤 passwordEncoder 를 사용하였는지 확인할 수 있고 이후 password 확인 시 저장된 값의 접두어에 해당하는 passwordEncoder를 사용하여 password가 일치하는지 확인한다.

DelegatingTextEncryptor 구현

Spring Cloud Config Server{chiper} 접두어 사용 방식은 일반적인 프로젝트에 적용할 수 있지만 이후 암호화 방식의 변경에 유연하지 못하다.

Spring SecurityDelegatingPasswordEncoder 사용 형태가 properties의 암호화를 다양하게 지원할 수 있고 이후 변경에도 유연하게 대응이 가능하다.
(또한 굳이 직접 만들어 쓰긴 해도 대부분의 코드 구현은 Spring SecurityDelegatingPasswordEncoder 의 코드를 참조했기 때문에 이후 관리할 때도 참고하기 편하다.)

TextEncryptor 의 경우 spring-security-crypto 에서는 HexEncodingTextEncryptor 만 제공하고 있고 spring-security-rsa 를 사용하면 추가로 RsaRawEncryptorRsaSecretEncryptor 를 사용할 수 있다.

일단 spring-security-crypto 에서 제공하는 HexEncodingTextEncryptor 를 사용한 경우만 가정하고 DelegatingTextEncryptor 를 구현하면 다음과 같다.

@Slf4j
public class DelegatingTextEncryptor implements TextEncryptor {

    private static final String DEFAULT_ID_PREFIX = "{";

    private static final String DEFAULT_ID_SUFFIX = "}";

    private final String idPrefix;

    private final String idSuffix;

    private final String defaultTextEncryptorId;

    private final Map<String, TextEncryptor> textEncryptorMap;

    public DelegatingTextEncryptor(String defaultTextEncryptorId, Map<String, TextEncryptor> textEncryptorMap) {
        this(defaultTextEncryptorId, textEncryptorMap, DEFAULT_ID_PREFIX, DEFAULT_ID_SUFFIX);
    }

    public DelegatingTextEncryptor(String defaultTextEncryptorId, Map<String, TextEncryptor> textEncryptorMap, String idPrefix, String idSuffix) {

        if (defaultTextEncryptorId == null) {
            throw new BlueskyException("defaultId cannot be null");
        }

        if (idPrefix == null) {
            throw new BlueskyException("prefix cannot be null");
        }

        if (idSuffix == null || idSuffix.isEmpty()) {
            throw new BlueskyException("suffix cannot be empty");
        }

        if (idPrefix.contains(idSuffix)) {
            throw new BlueskyException("idPrefix " + idPrefix + " cannot contain idSuffix " + idSuffix);
        }

        if (!textEncryptorMap.containsKey(defaultTextEncryptorId)) {
            throw new BlueskyException("defaultTextEncryptorId " + defaultTextEncryptorId + "is not found in textEncryptorMap" + textEncryptorMap);
        }

        for (String id : textEncryptorMap.keySet()) {
            if (id == null) {
                continue;
            }
            if (!idPrefix.isEmpty() && id.contains(idPrefix)) {
                throw new BlueskyException("id " + id + " cannot contain " + idPrefix);
            }
            if (id.contains(idSuffix)) {
                throw new BlueskyException("id " + id + " cannot contain " + idSuffix);
            }
        }

        this.defaultTextEncryptorId = defaultTextEncryptorId;
        this.textEncryptorMap = new HashMap<>(textEncryptorMap);
        this.idPrefix = idPrefix;
        this.idSuffix = idSuffix;
    }

    @Override
    public String encrypt(String text) {
        return encrypt(defaultTextEncryptorId, text);
    }

    public String encrypt(String textEncryptorId, String text) {
        return this.idPrefix + textEncryptorId + this.idSuffix + this.textEncryptorMap.get(textEncryptorId).encrypt(text);
    }

    /**
     * encryptedText는 textEncryptorId와 조합된 형태인 경우 해당 textEncryptor를 찾아 decrypt 처리
     */
    @Override
    public String decrypt(String encryptedText) {

        String textEncryptorId = extractTextEncryptorId(encryptedText);
        if (textEncryptorId == null) {
            return encryptedText;
        }

        if (!this.textEncryptorMap.containsKey(textEncryptorId)) {
            return encryptedText;
        }

        return this.textEncryptorMap.get(textEncryptorId).decrypt(extractEncryptedText(encryptedText));
    }

    public boolean isEncrypted(String encryptedText) {
        return extractTextEncryptorId(encryptedText) != null;
    }

    private String extractTextEncryptorId(String encryptedText) {
        if (encryptedText == null) {
            return null;
        }
        int start = encryptedText.indexOf(this.idPrefix);
        if (start != 0) {
            return null;
        }

        int end = encryptedText.indexOf(this.idSuffix, start);
        if (end < 0) {
            return null;
        }

        var textEncryptorId = encryptedText.substring(start + this.idPrefix.length(), end);
        if (!this.textEncryptorMap.containsKey(textEncryptorId)) {
            log.debug("The textEncryptorId does not exist in the textEncryptorMap : {}", textEncryptorId);
            return null;
        }
        return textEncryptorId;
    }

    public DelegatingTextEncryptor addTextEncryptor(String textEncryptorId, TextEncryptor textEncryptor) {
        this.textEncryptorMap.put(textEncryptorId, textEncryptor);
        return this;
    }

    public DelegatingTextEncryptor addTextEncryptor(Map<String, TextEncryptor> textEncryptorMap) {
        this.textEncryptorMap.putAll(textEncryptorMap);
        return this;
    }

    private String extractEncryptedText(String encryptedText) {
        if (encryptedText == null) {
            return encryptedText;
        }
        int start = encryptedText.indexOf(this.idPrefix);
        if (start != 0) {
            return encryptedText;
        }

        int end = encryptedText.indexOf(this.idSuffix, start);
        if (end < 0) {
            return encryptedText;
        }

        return encryptedText.substring(end + 1);
    }

    public boolean isTextEncryptorMapEmpty() {
        return CollectionUtils.isEmpty(this.textEncryptorMap);
    }

    public Set<String> textEncryptorMapKeySet() {
        return this.textEncryptorMap.keySet();
    }
}

TextEncryptorFactories 구현

해당 DelegatingTextEncryptor 를 생성할 TextEncryptorFactories 는 다음과 같다.
(Factories에 기본 등록할 TextEncryptor 와 함께 외부에서 추가 등록할 수 있도록 구성한 형태)

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class TextEncryptorFactories {

    private static DelegatingTextEncryptor delegatingTextEncryptor;

    private static String defaultTextEncryptorId = "text";

    public static DelegatingTextEncryptor getDelegatingTextEncryptor() {
        if (delegatingTextEncryptor == null) {
            createDelegatingTextEncryptor();
        }

        return delegatingTextEncryptor;
    }

    public static DelegatingTextEncryptor createDelegatingTextEncryptor() {
        var textEncryptorMap = new HashMap<String, TextEncryptor>();
        textEncryptorMap.putAll(getDefaultTextEncryptorMap());
        delegatingTextEncryptor = new DelegatingTextEncryptor(defaultTextEncryptorId, textEncryptorMap);
        return delegatingTextEncryptor;
    }

    private static Map<String, TextEncryptor> getDefaultTextEncryptorMap() {
        var textEncryptorMap = new HashMap<String, TextEncryptor>();
        textEncryptorMap.put("text", Encryptors.text("pass", "8560b4f4b3"));
        textEncryptorMap.put("delux", Encryptors.delux("pass", "8560b4f4b3"));
        return textEncryptorMap;
    }
}

위의 예에서는 spring-security-crypto 가 제공하는 HexEncodingTextEncryptor 기반 TextEncryptor를 text, delux 라는 id로 2개 등록해 보았다.

EnvironmentPostProcessor 구현

이를 사용하여 EnvironmentPostProcessor 를 다음과 같이 구현하였다.

@Slf4j
public class DecryptEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {

    /**
     * Name of the decrypted property source.
     */
    public static final String DECRYPTED_PROPERTY_SOURCE_NAME = "blueskyDecrypted";

    private static final Pattern COLLECTION_PROPERTY = Pattern.compile("(\\S+)?\\[(\\d+)\\](\\.\\S+)?");

    private int order = Ordered.LOWEST_PRECEDENCE;

    private DelegatingTextEncryptor textEncryptor;

    public DecryptEnvironmentPostProcessor() {
        textEncryptor = (DelegatingTextEncryptor) TextEncryptorFactories.getDelegatingTextEncryptor();
    }

    @Override
    public int getOrder() {
        return this.order;
    }

    @Override
    public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {

        if (textEncryptor == null || textEncryptor.isTextEncryptorMapEmpty()) {
            return;
        }

        MutablePropertySources propertySources = environment.getPropertySources();

        environment.getPropertySources().remove(DECRYPTED_PROPERTY_SOURCE_NAME);


        Map<String, Object> properties = merge(propertySources);
        decrypt(properties);

        if (!properties.isEmpty()) {
            propertySources.addFirst(new SystemEnvironmentPropertySource(DECRYPTED_PROPERTY_SOURCE_NAME, properties));
        }
    }

    protected Map<String, Object> merge(PropertySources propertySources) {
        Map<String, Object> properties = new LinkedHashMap<>();
        List<PropertySource<?>> sources = new ArrayList<>();
        for (PropertySource<?> source : propertySources) {
            sources.add(0, source);
        }
        for (PropertySource<?> source : sources) {
            merge(source, properties);
        }
        return properties;
    }

    protected void merge(PropertySource<?> source, Map<String, Object> properties) {
        if (source instanceof CompositePropertySource compositePropertySource) {

            List<PropertySource<?>> sources = new ArrayList<>(compositePropertySource.getPropertySources());
            Collections.reverse(sources);

            for (PropertySource<?> nested : sources) {
                merge(nested, properties);
            }

        }
        else if (source instanceof EnumerablePropertySource<?> enumerablePropertySource) {
            Map<String, Object> otherCollectionProperties = new LinkedHashMap<>();
            boolean sourceHasDecryptedCollection = false;

            for (String key : enumerablePropertySource.getPropertyNames()) {
                Object property = source.getProperty(key);
                if (property != null) {
                    String value = property.toString();

                    if (textEncryptor.isEncrypted(value)) {
                        properties.put(key, value);
                        if (COLLECTION_PROPERTY.matcher(key).matches()) {
                            sourceHasDecryptedCollection = true;
                        }
                    }
                    else if (COLLECTION_PROPERTY.matcher(key).matches()) {
                        // put non-encrypted properties so merging of index properties
                        // happens correctly
                        otherCollectionProperties.put(key, value);
                    }
                    else {
                        // override previously encrypted with non-encrypted property
                        properties.remove(key);
                    }
                }
            }
            // copy all indexed properties even if not encrypted
            if (sourceHasDecryptedCollection && !otherCollectionProperties.isEmpty()) {
                properties.putAll(otherCollectionProperties);
            }

        }
    }

    protected void decrypt(Map<String, Object> properties) {
        properties.replaceAll((key, value) -> {
            String valueString = value.toString();
            if (!textEncryptor.isEncrypted(valueString)) {
                return value;
            }
            return decrypt(key, valueString);
        });
    }

    protected String decrypt(String key, String original) {
        String value = original;
        try {
            value = textEncryptor.decrypt(value);
            log.debug("Decrypted: key=" + key);
            return value;
        }
        catch (Exception e) {
            String message = "Cannot decrypt: key=" + key;
            log.warn(message, e);
            throw new IllegalStateException(message, e);
        }
    }

}

이렇게 생성한 EnvironmentPostProcessorMETA-INF/spring.factories 에 추가하면 된다.

org.springframework.boot.env.EnvironmentPostProcessor=\
io.github.luversof.boot.security.crypto.env.DecryptEnvironmentPostProcessor

암호화 처리해 보기

암호화 처리할 값을 암호화하여 확인하고

@Test
void encryptTest() {
    var text = "test text!!!";
    var encryptor = BlueskyTextEncryptorFactories.createDelegatingTextEncryptor();
    var encryptText = encryptor.encrypt(text);
    log.debug("encryptText : {}", encryptText);
    var decryptText = encryptor.decrypt(encryptText);
    log.debug("decryptText : {}, {}", text.equals(decryptText), decryptText);
}
encryptText : {text}78df433ab0bc0cfddd2796b3298f75a083b2184040634509c4aee4f114e71168
decryptText : true, test text!!!

암호화된 값을 properties에 등록하고 호출해 보면 암호화된 값이 접두사에 표시된 id기준으로 DelegatingTextEncryptor를 통해 복호화되어 사용된다.

spring actuator를 사용하는 경우 /actuator/env 에 원래 암호화된 값이 있는 properties들은 값이 그대로 암호화되어 있고 EnvironmentPostProcessor 에서 구현한 바와 같이 별도의 propertySource에 복호화된 값이 설정되어 propertiesSources에 addFirst로 등록되어 호출 시엔 원래 값보다 복호화된 값이 우선 사용되는 것을 확인할 수 있다.

위의 예에서는 spring-security-crypto 가 기본 제공하는 HexEncodingTextEncryptor 만 사용하였는데 좋은 암호화 라이브러리, 암호화 방식이 있다면 해당 암호화로 TextEncryptor 를 구현하고 적절한 id를 부여하여 TextEncryptorFactories 에 등록하면 된다.

어떤 암호화가 좋은지는 잘 모르겠다.

반응형
profile

파란하늘의 지식창고

@Bluesky_

도움이 되었다면 광고를 클릭해주세요