Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions encryption/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package encryption

import (
"slices"
"time"

"github.com/hashicorp/golang-lru/v2/expirable"
"golang.org/x/sync/singleflight"
)

// CacheConfig configures the optional DEK (data encryption key) cache.
// A zero-value config disables caching.
type CacheConfig struct {
// MaxSize is the maximum number of DEKs to cache.
MaxSize int
// TTL is how long a cached DEK lives, counted from when it is stored.
// Values below minCacheTTL disable the cache.
TTL time.Duration
}

// minCacheTTL keeps expirable.LRU's eviction ticker, which runs at TTL/100,
// above 10ms. Below 100ns it panics outright, and a bare integer literal is a
// legal time.Duration, so a TTL meant as seconds lands in that range.
const minCacheTTL = time.Second

type dekCache struct {
lru *expirable.LRU[string, []byte]
group singleflight.Group
}

func newDEKCache(maxSize int, ttl time.Duration) *dekCache {
return &dekCache{lru: expirable.NewLRU[string, []byte](maxSize, nil, ttl)}
Comment thread
patrislav marked this conversation as resolved.
Comment thread
patrislav marked this conversation as resolved.
}

func (c *dekCache) get(keyRef string) ([]byte, bool) {
dek, ok := c.lru.Get(keyRef)
if !ok {
return nil, false
}
return slices.Clone(dek), true
}

func (c *dekCache) put(keyRef string, dek []byte) {
c.lru.Add(keyRef, slices.Clone(dek))
}

func (c *dekCache) delete(keyRef string) {
c.lru.Remove(keyRef)
}
135 changes: 135 additions & 0 deletions encryption/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package encryption

import (
"log/slog"
"sync"
"testing"
"time"

"github.com/stretchr/testify/require"
)

func TestWithCache_DisablesCacheBelowMinTTL(t *testing.T) {
quiet := slog.New(slog.DiscardHandler)

for _, cfg := range []CacheConfig{
{MaxSize: 10, TTL: 0},
{MaxSize: 0, TTL: time.Minute},
{MaxSize: 10, TTL: 50}, // would panic expirable.LRU
{MaxSize: 10, TTL: 600}, // reads as seconds, means nanoseconds
} {
p := NewPool(nil, nil, nil, nil, quiet, WithCache(cfg))
require.Nil(t, p.cache, "cache should be disabled for %+v", cfg)
}

p := NewPool(nil, nil, nil, nil, quiet, WithCache(CacheConfig{MaxSize: 10, TTL: minCacheTTL}))
require.NotNil(t, p.cache)
}

func TestDEKCache_GetPut(t *testing.T) {
c := newDEKCache(10, time.Minute)
dek := []byte("0123456789abcdef0123456789abcdef")

c.put("ref1", dek)

got, ok := c.get("ref1")
require.True(t, ok, "expected cache hit")
require.Equal(t, dek, got)

// Returned slice must be an independent copy.
got[0] = 0xFF
got2, _ := c.get("ref1")
require.NotEqual(t, byte(0xFF), got2[0], "cache returned same underlying slice, expected independent copy")

// Stored slice must be an independent copy of the input.
dek[0] = 0xAA
got3, _ := c.get("ref1")
require.NotEqual(t, byte(0xAA), got3[0], "cache stored same underlying slice as input, expected independent copy")
}

func TestDEKCache_Miss(t *testing.T) {
c := newDEKCache(10, time.Minute)

_, ok := c.get("unknown")
require.False(t, ok, "expected cache miss for unknown key")
}

func TestDEKCache_TTLExpiry(t *testing.T) {
c := newDEKCache(10, time.Millisecond)
c.put("ref1", []byte("0123456789abcdef0123456789abcdef"))

time.Sleep(5 * time.Millisecond)

_, ok := c.get("ref1")
require.False(t, ok, "expected cache miss after TTL expiry")
}

func TestDEKCache_LRUEviction(t *testing.T) {
c := newDEKCache(2, time.Minute)

c.put("ref1", []byte("key1key1key1key1key1key1key1key1"))
c.put("ref2", []byte("key2key2key2key2key2key2key2key2"))

// Make ref1 more recent than ref2, so ref3 evicts ref2.
_, _ = c.get("ref1")
c.put("ref3", []byte("key3key3key3key3key3key3key3key3"))

_, ok := c.get("ref2")
require.False(t, ok, "expected ref2 to be evicted (LRU)")
_, ok = c.get("ref1")
require.True(t, ok, "expected ref1 to still be cached")
_, ok = c.get("ref3")
require.True(t, ok, "expected ref3 to still be cached")
}

func TestDEKCache_PutUpdatesExisting(t *testing.T) {
c := newDEKCache(10, time.Minute)
dek2 := []byte("new_key_new_key_new_key_new_key_")

c.put("ref1", []byte("old_key_old_key_old_key_old_key_"))
c.put("ref1", dek2)

got, ok := c.get("ref1")
require.True(t, ok, "expected cache hit")
require.Equal(t, dek2, got)
}

func TestDEKCache_Delete(t *testing.T) {
c := newDEKCache(10, time.Minute)

c.put("ref1", []byte("0123456789abcdef0123456789abcdef"))
c.delete("ref1")

_, ok := c.get("ref1")
require.False(t, ok, "expected cache miss after delete")
}

func TestDEKCache_DeleteMissing(t *testing.T) {
c := newDEKCache(10, time.Minute)
c.delete("nonexistent")
}

func TestDEKCache_ConcurrentAccess(t *testing.T) {
c := newDEKCache(10, time.Minute)
dek := []byte("0123456789abcdef0123456789abcdef")

var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(3)
go func() {
defer wg.Done()
c.put("ref1", dek)
}()
go func() {
defer wg.Done()
c.get("ref1")
}()
go func(n int) {
defer wg.Done()
if n%10 == 0 {
c.delete("ref1")
}
}(i)
}
wg.Wait()
}
Loading
Loading