Skip to content

Commit

Permalink
Keyset cache implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
vladaspasic committed Sep 6, 2023
1 parent 1ed1b4a commit ee08943
Show file tree
Hide file tree
Showing 9 changed files with 267 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.cache.Cache;
import org.springframework.cache.support.NoOpCache;
import org.springframework.context.annotation.Bean;

/**
Expand Down Expand Up @@ -31,10 +33,15 @@ KeysetRepository inMemoryKeysetRepository() {
}

@Bean
KeysetStore repositoryKeysetStore(KeysetRepository repository, ObjectProvider<KeysetFactory> factories,
ObjectProvider<KeyEncryptionKeyProvider> providers) {
return new RepostoryKeysetStore(repository, factories.orderedStream().toList(),
providers.orderedStream().toList());
KeysetStore repositoryKeysetStore(KeysetRepository repository, ObjectProvider<KeysetCache> cache,
ObjectProvider<KeysetFactory> factories, ObjectProvider<KeyEncryptionKeyProvider> providers) {
return new RepostoryKeysetStore(cache.getIfAvailable(CryptoAutoConfiguration::createNoopKeysetCache),
repository, factories.orderedStream().toList(), providers.orderedStream().toList());
}

private static KeysetCache createNoopKeysetCache() {
final Cache delegate = new NoOpCache("noop-keyset-cache");
return new SpringKeysetCache(delegate);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,13 @@ public KeysetNotFoundException(String name, String message, Throwable cause) {

}

/**
* Exception thrown when the {@link Keyset} is being encrypted, or wrapped, by the
* responsible {@link KeyEncryptionKey}.
* <p>
* This exception contains both the name of {@link Keyset} and the actual
* {@link KeyEncryptionKey} values for which this exception has been thrown.
*/
@Getter
public static class WrappingException extends KeysetException {

Expand All @@ -318,6 +325,13 @@ public WrappingException(String key, KeyEncryptionKey kek, String message, Throw

}

/**
* Exception thrown when the {@link EncryptedKeyset} is being decrypted, or unwrapped,
* by the responsible {@link KeyEncryptionKey}.
* <p>
* This exception contains both the name of {@link EncryptedKeyset} and the actual
* {@link KeyEncryptionKey} values for which this exception has been thrown.
*/
@Getter
public static class UnwrappingException extends KeysetException {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.konfigyr.crypto;

import org.springframework.lang.NonNull;

import java.util.function.Supplier;

/**
* Interface that defines how encrypted cryptographic material is cached.
*
* @author : Vladimir Spasic
* @since : 06.09.23, Wed
**/
public interface KeysetCache {

/**
* Retrieves the {@link EncryptedKeyset} with the specified cached key, obtaining that
* value from {@link Supplier} if it is not present.
* <p>
* If possible, implementations of this method should ensure that the loading
* operation is synchronized so that the specified supplying function is only called
* once in case of concurrent access on the same key.
* @param key the key for which the {@link EncryptedKeyset} is to be returned, can't
* be {@literal null}
* @param supplier function that would obtain the value if the cached value is not
* present in the cache, can't be {@literal null}
* @return cached encrypted keyset, never {@literal null}
*/
EncryptedKeyset get(@NonNull String key, @NonNull Supplier<EncryptedKeyset> supplier);

/**
* Stores the {@link EncryptedKeyset} value with the specified key in this cache.
* @param key the key for which the {@link EncryptedKeyset} is to be associated, can't
* be {@literal null}
* @param keyset the {@link EncryptedKeyset} value to be associated with the specified
* key
*/
void put(@NonNull String key, @NonNull EncryptedKeyset keyset);

/**
* Evicts the {@link EncryptedKeyset} for this key from this cache if it is present.
* @param key the key for which the {@link EncryptedKeyset} is to be evicted, can't be
* {@literal null}
*/
void evict(@NonNull String key);

}
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ public interface KeysetStore {
* can't be {@literal null}.
* @param definition definition to be used when creating a new keyset, can't be
* {@literal null}.
* @return the generate {@link Keyset}, never {@literal null}
* @throws com.konfigyr.crypto.CryptoException.ProviderNotFoundException when provider
* with a given name does not exist
* @throws com.konfigyr.crypto.CryptoException.KeyEncryptionKeyNotFoundException when
Expand All @@ -133,6 +134,7 @@ default Keyset create(@NonNull String provider, @NonNull String kek, @NonNull Ke
* can't be {@literal null}.
* @param definition definition to be used when creating a new keyset, can't be
* {@literal null}.
* @return the generate {@link Keyset}, never {@literal null}
* @throws com.konfigyr.crypto.CryptoException.ProviderNotFoundException when provider
* with a given name does not exist
* @throws com.konfigyr.crypto.CryptoException.KeyEncryptionKeyNotFoundException when
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.Cache;
import org.springframework.lang.NonNull;

import java.io.IOException;
Expand Down Expand Up @@ -32,6 +33,8 @@ public final class RepostoryKeysetStore implements KeysetStore {

private final Logger logger = LoggerFactory.getLogger(getClass());

private final KeysetCache cache;

private final KeysetRepository repository;

private final List<KeysetFactory> factories;
Expand Down Expand Up @@ -101,6 +104,8 @@ public void remove(@NonNull String name) {
catch (IOException e) {
throw new KeysetException(name, "Could not remove encrypted keyset with name: " + name, e);
}

cache.evict(name);
}

@Override
Expand All @@ -123,12 +128,14 @@ public void remove(@NonNull Keyset keyset) {
logger.debug("Looking up encrypted keyset with name: {}", name);
}

try {
return repository.read(name).orElseThrow(() -> new KeysetNotFoundException(name));
}
catch (IOException e) {
throw new KeysetException(name, "Could not read encrypted keyset with name: " + name, e);
}
return cache.get(name, () -> {
try {
return repository.read(name).orElseThrow(() -> new KeysetNotFoundException(name));
}
catch (IOException e) {
throw new KeysetException(name, "Could not read encrypted keyset with name: " + name, e);
}
});
}

/**
Expand Down Expand Up @@ -244,6 +251,8 @@ protected void write(@NonNull KeysetFactory factory, @NonNull Keyset keyset) {
throw new KeysetException(keyset.getName(),
"Could not write encrypted keyset with name: " + keyset.getName(), e);
}

cache.put(encryptedKeyset.getName(), encryptedKeyset);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.konfigyr.crypto;

import lombok.RequiredArgsConstructor;
import org.springframework.cache.Cache;
import org.springframework.lang.NonNull;

import java.util.function.Supplier;

/**
* Implementation of the {@link KeysetCache} that uses Spring {@link Cache} implementation
* as the actual cache store.
*
* @author : Vladimir Spasic
* @since : 06.09.23, Wed
**/
@RequiredArgsConstructor
public class SpringKeysetCache implements KeysetCache {

private final Cache cache;

@Override
public synchronized EncryptedKeyset get(@NonNull String key, @NonNull Supplier<EncryptedKeyset> supplier) {
EncryptedKeyset encryptedKeyset = cache.get(key, EncryptedKeyset.class);

if (encryptedKeyset != null) {
return encryptedKeyset;
}

encryptedKeyset = supplier.get();

if (encryptedKeyset == null) {
throw new IllegalStateException("Keyset cache detected a null encrypted keyset value for " + "cache key '"
+ key + "'. This is currently not supported by this cache implementation.");
}

put(key, encryptedKeyset);

return encryptedKeyset;
}

@Override
public synchronized void put(@NonNull String key, @NonNull EncryptedKeyset keyset) {
cache.put(key, keyset);
}

@Override
public synchronized void evict(@NonNull String key) {
cache.evict(key);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.cache.concurrent.ConcurrentMapCache;
import org.springframework.lang.NonNull;

import java.io.IOException;
Expand Down Expand Up @@ -35,6 +36,9 @@ class RepostoryKeysetStoreTest {
@Mock
Keyset keyset;

@Spy
KeysetCache cache = new SpringKeysetCache(new ConcurrentMapCache("tesst-cache"));

@Spy
KeysetRepository repository = new InMemoryKeysetRepository();

Expand All @@ -49,7 +53,7 @@ void setup() {
.keyEncryptionKey("test-kek")
.build(ByteArray.fromString("encrypted material"));

store = new RepostoryKeysetStore(repository, List.of(factory), List.of(provider));
store = new RepostoryKeysetStore(cache, repository, List.of(factory), List.of(provider));
}

@Test
Expand All @@ -65,6 +69,8 @@ void shouldCreateKeyset() throws IOException {

assertThat(repository.read(definition.getName())).hasValue(encryptedKeyset);

assertThat(cache.get(definition.getName(), () -> null)).isEqualTo(encryptedKeyset);

verify(factory).create(keyset);
verify(factory).create(kek, definition);
verify(repository).write(encryptedKeyset);
Expand All @@ -81,10 +87,27 @@ void shouldReadKeyset() throws IOException {

assertThat(store.read(definition.getName())).isEqualTo(keyset);

assertThat(cache.get(definition.getName(), () -> null)).isEqualTo(encryptedKeyset);

verify(factory).create(kek, encryptedKeyset);
verify(repository).read(definition.getName());
}

@Test
void shouldReadKeysetFromCache() throws IOException {
doReturn(encryptedKeyset.getProvider()).when(provider).getName();
doReturn(kek).when(provider).provide(encryptedKeyset);
doReturn(true).when(factory).supports(encryptedKeyset);
doReturn(keyset).when(factory).create(kek, encryptedKeyset);

cache.put(encryptedKeyset.getName(), encryptedKeyset);

assertThat(store.read(definition.getName())).isEqualTo(keyset);

verify(factory).create(kek, encryptedKeyset);
verifyNoInteractions(repository);
}

@Test
void shouldWriteKeyset() throws IOException {
doReturn(true).when(factory).supports(keyset);
Expand All @@ -94,8 +117,11 @@ void shouldWriteKeyset() throws IOException {

assertThat(repository.read(definition.getName())).hasValue(encryptedKeyset);

assertThat(cache.get(definition.getName(), () -> null)).isEqualTo(encryptedKeyset);

verify(factory).create(keyset);
verify(repository).write(encryptedKeyset);
verify(cache).put(definition.getName(), encryptedKeyset);
}

@Test
Expand All @@ -115,11 +141,14 @@ void shouldRotateKeysetByName() throws IOException {

assertThatNoException().isThrownBy(() -> store.rotate(definition.getName()));

assertThat(cache.get(definition.getName(), () -> null)).isEqualTo(rotatedEncryptedKeyset);

verify(keyset).rotate();
verify(factory).create(kek, encryptedKeyset);
verify(factory).create(rotated);
verify(repository).read(definition.getName());
verify(repository).write(rotatedEncryptedKeyset);
verify(cache).put(definition.getName(), rotatedEncryptedKeyset);
}

@Test
Expand All @@ -132,9 +161,12 @@ void shouldRotateKeyset() throws IOException {

assertThatNoException().isThrownBy(() -> store.rotate(keyset));

assertThat(cache.get(definition.getName(), () -> null)).isEqualTo(encryptedKeyset);

verify(keyset).rotate();
verify(factory).create(rotated);
verify(repository).write(encryptedKeyset);
verify(cache).put(definition.getName(), encryptedKeyset);
}

@Test
Expand All @@ -144,13 +176,15 @@ void shouldRemoveKeyset() throws IOException {
assertThatNoException().isThrownBy(() -> store.remove(keyset));

verify(repository).remove(definition.getName());
verify(cache).evict(definition.getName());
}

@Test
void shouldRemoveKeysetByName() throws IOException {
assertThatNoException().isThrownBy(() -> store.remove(definition.getName()));

verify(repository).remove(definition.getName());
verify(cache).evict(definition.getName());
}

@Test
Expand Down
Loading

0 comments on commit ee08943

Please sign in to comment.