Protect sensitive data in an event-sourced application

In message-driven systems, messages flow in many directions. This behavior helps to decouple applications to make these systems better scalable. When it comes to sensitive data, we need to know who has access to this information. To prevent unauthorized people from reading sensitive data, you can apply encryption to these fields. A key should be generated for every resource. This key is stored and only given to the consumers entitled to process this sensitive data according to the security regulations (like the General Data Protection Regulation). Encryption of sensitive data has more advantages. For example, when logging exceptions, no sensitive information will be present in the application logs when this data is encrypted. Production data where personal information is encrypted can more easily be used for testing.

When working in an event-sourced application, the events are the single source of truth. This makes the event store the place to encrypt sensitive data attributes. In such an application, the events are significant because they will provide an audit trail and rebuild the state. Your application may not function properly anymore when you delete events, and that’s why events are immutable; they cannot be updated or deleted. 

The right to data erasure is a requirement that is common in security laws. At first glance, the right to erasure seems to be contradictory to event sourcing. A solution to overcome this problem is crypto shredding. Crypto shredding is the concept where one deletes sensitive data by deleting the encryption key used to encrypt it. If the encryption is sufficiently strong and the encryption key is lost, you cannot decrypt the data anymore. 

The Data Protection module 

AxonIQ’s Data Protection Module takes a declarative approach to encrypt sensitive data. It can be used to restrict access to information by certain components, as well as for crypto shredding. This module is available for different versions of Axon but can also be used without Axon. 

The Data Protection Module uses Advanced Encryption Standard (AES) as an encryption algorithm. The default key size is 256 bits, which is highly recommended, but you can also use 128 or 192 bits.

The CryptoEngine is the driving part of the data protection module. You can:

  • Retrieve an already existing key 
  • Given an id, retrieve the existing key or generate a new one
  • Delete a key
  • Override the default key length for new keys
  • Obtain the Java cryptography Cipher object for performing actual encryption

Except for the deletion of a key, these methods are used by the FieldEncrypter class and not in your application code.

Six implementations of the CryptoEngine can be used:

  1. VaultCryptoEngine to store the keys in HashiCorp Vault
  2. InMemoryCryptoEngine for unit testing
  3. JPACryptoEngine to store the keys with JPA
  4. JdbcCryptoEngine for plain JDBC instead of JPA
  5. JavaKeyStoreCryptoEngine which uses the Java Keystore to store the keys
  6. PKCS11CryptoEngine that supports Hardware Security Modules (HSM) using PKCS11

Getting started

To give you an idea of how it works, I will implement the Data Protection Module in an example Springboot Axon application using Java and Kotlin. 

First, you need to add the provided jar to your project. There are four different versions available in the download package: axon4, axon3, axon2, and core. The latter does not depend on Axon at all.

In this blog, we use the axon4 version of the module:


<dependency>
    <groupId>io.axoniq.dataprotection</groupId>
    <artifactId>axon-data-protection-axon4</artifactId>
    <version>4.1</version>
 </dependency>

The next two chapters explain how to configure an application with the JPACryptoEngine and the VaultCryptoEngine.

Setup using the JPACryptoEngine

The JPACryptoEngine stores the secret keys in a table in a relational database. To use it, add these beans to the Spring configuration:


    @Bean
    public CryptoEngine cryptoEngine(EntityManagerfactory entityManagerfactory) {
        return new JpaCryptoEngine(entityManagerfactory);
    }

    @Bean("eventSerializer")
    public Serializer eventSerializer(CryptoEngine cryptoEngine, Serializer messageSerializer) {
        return new FieldEncryptingSerializer(cryptoEngine, messageSerializer);
    }

The event serializer needs to be configured to use the FieldEncryptingSerializer. This is a wrapper class for the Serializer and will encrypt the sensitive data before applying the serialization. 

The next step is to create the table for the secret keys:


    CREATE TABLE IF NOT EXISTS axoniq_gdpr_keys
    (
       key_id.    VARCHAR(255) NOT NULL,
       secret_key VARCHAR(255) NOT NULL,
       PRIMARY KEY (key_id)
    );

Setup using the VaultCryptoEngine

The VaultCrytoEngine uses HashiCorp Vault to save the keys. In general, Vault provides a more secure location to store the keys, which themselves should be treated as sensitive data too. The following configuration enables the VaultCryptoEngine:


    @Bean
    public OkHttpClient okHttpClient() {return new OkHttpClient();}

    @Bean
    public CryptoEngine cryptoEngine(OkHttpClient okHttpClient) {
        return new VaultCryptoEngine(okHttpClient,
                    /* address: */ "http://localhost:8200",
                    /* token: */ "s.VGzifiHuUuzbQjFew7FMWESF",
                    /* prefix: */ "hotel-keys");
    }

 The prefix configured here is the name of the KV secrets engine used in the Vault Server. The version of that engine should be 1:

sensitive3

Identify the sensitive data

Now that the CryptoEngine is set up for managing the keys, it is time to identify our sensitive data. In our example, when an Account is registered, the AccountRegisteredEvent is published. We will add  the PersonalData annotation on the password of the account to mark it as sensitive data and use the accountId as the identifier of the data subject:


    data class AccountRegisteredEvent(
        @DataSubjectId(group = "account", prefix = "account-")
        val accountId: UUID,
        val username: String,
        @PersonalData(group = "account")
        val password: String
    )

The prefix is optional and will be used in the Keystore to distinguish between the different data subjects. An optional attribute tells the Data Protection module which category of sensitive data this field belongs to. Fields in different groups will use different encryption keys, allowing you to control each group's access separately.

Running the application

Before you start the application, you need to provide it with a valid license file. You can store the license on your local machine and point to it by adding the VM option: 


    -D axoniq.dataprotection.license=path-to-your-licence/axoniq.license

You can run AxonServer locally and start the application and apply the AccountRegisteredEvent with a password Welcome1. This event will be stored in the event store, and the payload looks like this:

sensitive4
 

You can see that the password was saved in an encrypted format. If you have configured the VaultCryptoEngine, you can see these entries in your Vault server:

sensitive2

The Data Protection Module in action

The examples used in this blog were tied to Axon, but the Data Protection Module can also be used without it. The unit tests below, written in Kotlin, showcase the capabilities of the data protection module.

Example 1 

Encrypt and decrypt the sensitive data inside the AccountRegisteredEvent:


    @Test
    fun encryptAccountRegisteredEventTest() {
            val accountRegisteredEvent = AccountRegisteredEvent(UUID.randomUUID(), /* username */ "Name", /*password*/ "Welcome1"
            fieldEncrypter.encrypt(accountRegisteredEvent)
            assertNotEquals("Welcome1", accountRegisteredEvent.password)
            logger.info("Encrypted password [${accountRegisteredEvent.password}]")
    
            fieldEncrypter.decrypt(accountRegisteredEvent)
            assertEquals("Welcome1", accountRegisteredEvent.password)
    }

The output of the logger statement:

Encryptedpassword [CAEVlUhITxoQLm3mZGxmGtnTG1NwEQfwPSFMJZjivdkLQinruTXtUGHa1g==]

This encrypted value of the password is now saved in the password field.

Example 2 

Encrypt and decrypt data in custom objects annotate them with DeepPersonalData.

In this code sample, some new annotations are being used. The DeepPersonalData annotation tells the DP module that it needs to look into the Address object to find more annotated fields.


data class AccountDataRegisteredEvent(
    @DataSubjectId
    val accountId: UUID,
    @DeepPersonalData
    val address: Address
)

data class Address(
    @PersonalData(replacement = "street")
    val street: String,
    @SerializedPersonalData
    val houseNumber: Int,
    val houseNumberEncrypted: ByteArray? = null,
    val zip: String,
    @PersonalData
    val city: String)

The house number in the Address object is annotated as SerializedPersonalData, and an extra field houseNumberEncrypted was introduced, defined as a byteArray. This additional field is needed because the encrypted house number can not be stored in a defined field.  

@Test
fun deepPersonalDataTest() {
    val accountDataRegisteredEvent =
            AccountDataRegisteredEvent(UUID.randomUUID(), Address(
                    /*street*/ "Broadway",
                    /*houseNumber*/ 10,
                    /*houseNumberEncrypted*/ null,
                    /*zip*/ "zip",
                    /*city*/ "city"))

    fieldEncrypter.encrypt(accountDataRegisteredEvent)
    logger.info("Encrypted address [${accountDataRegisteredEvent.address}]")
    assertNotEquals("Broadway", accountDataRegisteredEvent.address.street)
    assertNotEquals("city", accountDataRegisteredEvent.address.city)
    assertEquals("zip", accountDataRegisteredEvent.address.zip)
    assertEquals(0, accountDataRegisteredEvent.address.houseNumber)
    assertNotNull(accountDataRegisteredEvent.address.houseNumberEncrypted)

    fieldEncrypter.decrypt(accountDataRegisteredEvent)
    assertEquals("Broadway", accountDataRegisteredEvent.address.street)
    assertEquals("city", accountDataRegisteredEvent.address.city)
    assertEquals("zip", accountDataRegisteredEvent.address.zip)
    assertEquals(10, accountDataRegisteredEvent.address.houseNumber)
    assertNotNull(accountDataRegisteredEvent.address.houseNumberEncrypted)
}

When running this test, the output of the log statement is:

Encryptedaddress [Address(street=CAEVanORchoQ7mXP0GTuig8piCE7PGc+tSHMhjm+YxulXik+L0TdBLQu5A==, houseNumber=0, houseNumberEncrypted=[8, 1, 18, 3, 105, 110, 116, 34, 43, 8, 1, 21, -91, 93, 39, 86, 26, 16, -13, -45, -102, 96, 78, 120, 96, 72, 63, -104, -125, -102, 87, -27, -17, 7, 33, -30, 98, -73, 63, 100, 106, -31, 72, 41, -32, 49, -36, -109, -57, -77, -47, -52], zip=zip, city=CAEVdVSjAhoQeBE8kG4862AIZXOYilp9giGu11H0CmGTOCn3PVgEI55Mag==)]

 

Example 3 Crypto shredding


@Test
fun cryptoShreddingTest() {
    val accountDataRegisteredEvent = AccountDataRegisteredEvent(UUID.randomUUID(),
            Address(street="Broadway", houseNumber = 10, zip = "zip", city = "city"))
    fieldEncrypter.encrypt(accountDataRegisteredEvent)

    cryptoEngine.deleteKey(accountDataRegisteredEvent.accountId.toString())
    fieldEncrypter.decrypt(accountDataRegisteredEvent)

    // the street has been replaced with the replacement value
    assertEquals("replacementValue", accountDataRegisteredEvent.address.street)
    // the city has been replaced with the default replacement value which is an empty String
    assertEquals(EMPTY, accountDataRegisteredEvent.address.city)
    // the zip has not been replaced since there was no PersonalData annotation
    assertEquals("zip", accountDataRegisteredEvent.address.zip)
    // the house number has been replaced with the default replacement value which is 0
    assertEquals(0, accountDataRegisteredEvent.address.houseNumber)
    // the encrypted house number is null again
    assertNull(accountDataRegisteredEvent.address.houseNumberEncrypted)
}

In this test, the event is encrypted, and then the key is deleted. After decrypting the street, house number and city are replaced by either an empty String, a replacement value, or (in case of a number) by 0. It is possible to customize this behavior, as shown in the next example.

 

Example 4: Replacement behavior when the key is deleted

An event store contains valuable information that you might want to keep for analytics. When deleting sensitive data, you may want to keep the year of birth in the case of the date of birth. This can be done with a custom implementation of the ReplacementValueProvider:


    class DateOfBirthReplacementProvider : ReplacementValueProvider() {
        override fun replacementValue(clazz: Class<*>?, field: Field?, fieldType: Type?, groupName: String?,
                                      replacement: String?, storedPartialValue: ByteArray?): Any? {
            if (fieldType == LocalDate::class.java && replacement == "YEAR_ONLY" && storedPartialValue != null) {
                val buffer = ByteBuffer.allocate(Integer.BYTES)
                buffer.put(storedPartialValue)
                buffer.flip()
                return LocalDate.of(buffer.getInt(), Month.JANUARY, /*dayOfMonth*/ 1)
            } else return super.replacementValue(clazz, field, fieldType, groupName, replacement, storedPartialValue)
        }
    
        override fun partialValueForStorage(clazz: Class<*>?, field: Field?, fieldType: Type?, groupName: String?,
                                            replacement: String?, inputValue: Any?): ByteArray? {
            if (fieldType == LocalDate.class.java &&
                    replacement == "YEAR_ONLY" &&
                    inputValue!!::class.java.isAssignableFrom(LocalDate::class.java)) {
                val buffer = ByteBuffer.allocate(Integer.BYTES)
                buffer.putInt((inputValue as LocalDate).year)
                return buffer.array()
            } else {
                return super.partialValueForStorage(clazz, field, fieldType, groupName, replacement, inputValue)
            }
        }
    }

In the unit test, you can provide this class to the FieldEncrypter:


    @Test
    fun cryptoShreddingWithReplacementValueTest() {
        val dateOfBirthFieldEncrypter = FieldEncrypter(cryptonEngine, dateOfBirthReplacementProvider)
        val dateOfBirthRegisteredEvent =
                DateOfBirthRegisteredEvent(UUID.randomUUID(), LocalDate.parse("1998-07-07"))
    
        dateOfBirthFieldEncrypter.encrypt(dateOfBirthRegisteredEvent)
        assertNull(dateOfBirthRegisteredEvent.dateOfBirth)
        logger.info("birth date encrypted [${dateOfBirthRegisteredEvent.dateOfBirthEncrypted}]")
    
        cryptoEngine.deleteKey(dateOfBirthRegisteredEvent.accountId.toString())
        dateOfBirthFieldEncrypter.decrypt(dateOfBirthRegisteredEvent)
        assertNull(dateOfBirthRegisteredEvent.dateOfBirthEncrypted)
        assertEquals(LocalDate.parse("1998-01-01"), dateOfBirthRegisteredEvent.dateOfBirth)
    }

As you can see, the date of birth changed into Jan 1st of the year of birth.

Conclusion

The Data Protection Module can help you to implement security regulations like GDPR by restricting access to sensitive data in distributed systems. In some jurisdictions, crypto shredding alone may not be feasible because, in theory, the encrypted data can still be decrypted. They state that brute force cracking a key may be a matter of hardware, time, and technological progress. In that case, you may want to think about putting your sensitive data in a secure database instead of saving them in the events. You could use the replacement feature in the data protection module to add a reference to it. GDPR, for instance, does not prescribe which deletion method should be used. 

The Data Protection Module is commercial software. For information about the software, pricing, and licensing, send an email to sales@axoniq.io.

Yvonne Ceelie
Yvonne has more than two decades of experience and is passionate about Java and the Spring Framework. She focusses on helping companies to use and implement the Axon Framework.
Yvonne Ceelie

Share: