Repository: google/certificate-transparency-go
Pull request #1452 (2024-04-29 19:16:54)

Add issuance chain service
<!---
Describe your changes in detail here.
If this fixes an issue, please write "Fixes #123", substituting the issue number.
-->
The issuance chain service contributes to https://github.com/google/certificate-transparency-go/issues/691.

### Checklist

<!---
Go over all the following points, and put an `x` in all the boxes that apply.
Feel free to not tick any boxes that don't apply to this PR (e.g. refactoring may not need a CHANGELOG update).
If you're unsure about any of these, don't hesitate to ask. We're here to help!
-->

- [ ] I have updated the [CHANGELOG](CHANGELOG.md).
  - Adjust the draft version number according to [semantic versioning](https://semver.org/) rules.
- [ ] I have updated [documentation](docs/) accordingly.


roger2hk: Tests to be added.

Patch:
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 8051c21c..6ac1a545 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -17,7 +17,7 @@ jobs:
         with:
           go-version: "1.21"
       - name: golangci-lint
-        uses: golangci/golangci-lint-action@82d40c283aeb1f2b6595839195e95c2d6a49081b # v5.0.0
+        uses: golangci/golangci-lint-action@9d1e0624a798bb64f6c3cea93db47765312263dc # v5.1.0
         with:
           version: v1.55.1
           args: --timeout=5m
diff --git a/go.mod b/go.mod
index 5416d31a..84ac1d45 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,7 @@ require (
 	github.com/google/go-cmp v0.6.0
 	github.com/google/trillian v1.6.0
 	github.com/gorilla/mux v1.8.1
+	github.com/hashicorp/golang-lru/v2 v2.0.7
 	github.com/kylelemons/godebug v1.1.0
 	github.com/mattn/go-sqlite3 v1.14.22
 	github.com/prometheus/client_golang v1.19.0
@@ -24,7 +25,7 @@ require (
 	golang.org/x/net v0.24.0
 	golang.org/x/time v0.5.0
 	google.golang.org/grpc v1.63.2
-	google.golang.org/protobuf v1.33.1-0.20240408130810-98873a205002
+	google.golang.org/protobuf v1.34.0
 	gopkg.in/yaml.v3 v3.0.1
 	k8s.io/klog/v2 v2.120.1
 )
diff --git a/go.sum b/go.sum
index 15dc5397..ae56e07c 100644
--- a/go.sum
+++ b/go.sum
@@ -140,6 +140,8 @@ github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4
 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
 github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0 h1:RtRsiaGvWxcwd8y3BiRZxsylPT8hLWZ5SPcfI+3IDNk=
 github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0/go.mod h1:TzP6duP4Py2pHLVPPQp42aoYI92+PCrVotyR5e8Vqlk=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/jhump/protoreflect v1.16.0 h1:54fZg+49widqXYQ0b+usAFHbMkBGR4PpXrsHc8+TBDg=
@@ -444,8 +446,8 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.33.1-0.20240408130810-98873a205002 h1:V7Da7qt0MkY3noVANIMVBk28nOnijADeOR3i5Hcvpj4=
-google.golang.org/protobuf v1.33.1-0.20240408130810-98873a205002/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
+google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/trillian/ctfe/cache/lru/lru.go b/trillian/ctfe/cache/lru/lru.go
new file mode 100644
index 00000000..400e8df8
--- /dev/null
+++ b/trillian/ctfe/cache/lru/lru.go
@@ -0,0 +1,51 @@
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package lru defines the IssuanceChainCache type, which implements IssuanceChainCache interface with Get and Set operations.
+package lru
+
+import (
+	"context"
+	"time"
+
+	"github.com/hashicorp/golang-lru/v2/expirable"
+)
+
+type CacheOption struct {
+	Size int
+	TTL  time.Duration
+}
+
+type IssuanceChainCache struct {
+	opt   CacheOption
+	cache *expirable.LRU[string, []byte]
+}
+
+func NewIssuanceChainCache(opt CacheOption) *IssuanceChainCache {
+	cache := expirable.NewLRU[string, []byte](int(opt.Size), nil, opt.TTL)
+	return &IssuanceChainCache{
+		opt:   opt,
+		cache: cache,
+	}
+}
+
+func (c *IssuanceChainCache) Get(_ context.Context, key []byte) ([]byte, error) {
+	chain, _ := c.cache.Get(string(key))
+	return chain, nil
+}
+
+func (c *IssuanceChainCache) Set(_ context.Context, key []byte, chain []byte) error {
+	c.cache.Add(string(key), chain)
+	return nil
+}
diff --git a/trillian/ctfe/services.go b/trillian/ctfe/services.go
new file mode 100644
index 00000000..580cf876
--- /dev/null
+++ b/trillian/ctfe/services.go
@@ -0,0 +1,89 @@
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ctfe
+
+import (
+	"context"
+	"crypto/sha256"
+
+	"github.com/google/certificate-transparency-go/trillian/ctfe/cache"
+	"github.com/google/certificate-transparency-go/trillian/ctfe/storage"
+	"k8s.io/klog/v2"
+)
+
+type issuanceChainService struct {
+	storage storage.IssuanceChainStorage
+	cache   cache.IssuanceChainCache
+}
+
+func newIssuanceChainService(s storage.IssuanceChainStorage, c cache.IssuanceChainCache) *issuanceChainService {
+	service := &issuanceChainService{
+		storage: s,
+		cache:   c,
+	}
+
+	return service
+}
+
+// GetByHash returns the issuance chain with hash as the input.
+func (s *issuanceChainService) GetByHash(ctx context.Context, hash []byte) ([]byte, error) {
+	// Return if found in cache.
+	chain, err := s.cache.Get(ctx, hash)
+	if chain != nil || err != nil {
+		return chain, err
+	}
+
+	// Find in storage if cache miss.
+	chain, err = s.storage.FindByKey(ctx, hash)
+	if err != nil {
+		return nil, err
+	}
+
+	// If there is any error from cache set, do not return the error because
+	// the chain is still available for read.
+	go func(ctx context.Context, hash, chain []byte) {
+		if err := s.cache.Set(ctx, hash, chain); err != nil {
+			klog.Errorf("failed to set hash and chain into cache: %v", err)
+		}
+	}(ctx, hash, chain)
+
+	return chain, nil
+}
+
+// Add adds the issuance chain into the storage and cache and returns the hash
+// of the chain.
+func (s *issuanceChainService) Add(ctx context.Context, chain []byte) ([]byte, error) {
+	hash := issuanceChainHash(chain)
+
+	if err := s.storage.Add(ctx, hash, chain); err != nil {
+		return nil, err
+	}
+
+	// If there is any error from cache set, do not return the error because
+	// the chain is already stored.
+	go func(ctx context.Context, hash, chain []byte) {
+		if err := s.cache.Set(ctx, hash, chain); err != nil {
+			klog.Errorf("failed to set hash and chain into cache: %v", err)
+		}
+	}(ctx, hash, chain)
+
+	return hash, nil
+}
+
+// issuanceChainHash returns the SHA-256 hash of the chain.
+func issuanceChainHash(chain []byte) []byte {
+	checksum := sha256.Sum256(chain)
+	return checksum[:]
+}
diff --git a/trillian/examples/deployment/docker/ctfe/Dockerfile b/trillian/examples/deployment/docker/ctfe/Dockerfile
index c71b01c4..ee707948 100644
--- a/trillian/examples/deployment/docker/ctfe/Dockerfile
+++ b/trillian/examples/deployment/docker/ctfe/Dockerfile
@@ -12,7 +12,7 @@ COPY . .
 
 RUN go build ./trillian/ctfe/ct_server
 
-FROM gcr.io/distroless/base-debian12@sha256:611d30d7f6d9992c37b1e1a212eefdf1f7c671deb56db3707e24eb01da8c4c2a
+FROM gcr.io/distroless/base-debian12@sha256:d8d01e2d5868f622544543ca0311679bbc2f3fbf7f7bafd11af78a284c479ea3
 
 COPY --from=build /build/ct_server /
 


Test patch:
diff --git a/trillian/ctfe/cache/lru/lru_test.go b/trillian/ctfe/cache/lru/lru_test.go
new file mode 100644
index 00000000..19e61567
--- /dev/null
+++ b/trillian/ctfe/cache/lru/lru_test.go
@@ -0,0 +1,68 @@
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lru
+
+import (
+	"bytes"
+	"context"
+	"crypto/sha256"
+	"os"
+	"testing"
+)
+
+func TestLRUIssuanceChainCache(t *testing.T) {
+	cacheSize := 3
+
+	tests := setupTestData(t,
+		"leaf00.chain",
+		"leaf01.chain",
+		"leaf02.chain",
+	)
+
+	cache := NewIssuanceChainCache(CacheOption{Size: cacheSize})
+
+	for key, val := range tests {
+		if err := cache.Set(context.Background(), []byte(key), val); err != nil {
+			t.Errorf("cache.Set: %v", err)
+		}
+	}
+
+	for key, want := range tests {
+		got, err := cache.Get(context.Background(), []byte(key))
+		if err != nil {
+			t.Errorf("cache.Get: %v", err)
+		}
+		if !bytes.Equal(got, want) {
+			t.Errorf("got: %v, want: %v", got, want)
+		}
+	}
+}
+
+func setupTestData(t *testing.T, filenames ...string) map[string][]byte {
+	t.Helper()
+
+	data := make(map[string][]byte, len(filenames))
+
+	for _, filename := range filenames {
+		val, err := os.ReadFile("../../../testdata/" + filename)
+		if err != nil {
+			t.Fatal(err)
+		}
+		key := sha256.Sum256(val)
+		data[string(key[:])] = val
+	}
+
+	return data
+}
diff --git a/trillian/ctfe/services_test.go b/trillian/ctfe/services_test.go
new file mode 100644
index 00000000..3a9f7c12
--- /dev/null
+++ b/trillian/ctfe/services_test.go
@@ -0,0 +1,116 @@
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ctfe
+
+import (
+	"bytes"
+	"context"
+	"crypto/sha256"
+	"os"
+	"sync"
+	"testing"
+)
+
+func TestIssuanceChainServiceAddAndGet(t *testing.T) {
+	tests := []struct {
+		chain []byte
+	}{
+		{readTestData(t, "leaf00.chain")},
+		{readTestData(t, "leaf01.chain")},
+		{readTestData(t, "leaf02.chain")},
+		{nil},
+	}
+
+	ctx := context.Background()
+	storage := &fakeIssuanceChainStorage{}
+	cache := &fakeIssuanceChainCache{}
+	issuanceChainService := newIssuanceChainService(storage, cache)
+
+	for _, test := range tests {
+		hash, err := issuanceChainService.Add(ctx, test.chain)
+		if err != nil {
+			t.Errorf("IssuanceChainService.Add(): %v", err)
+		}
+
+		got, err := issuanceChainService.GetByHash(ctx, hash)
+		if err != nil {
+			t.Errorf("IssuanceChainService.GetByHash(): %v", err)
+		}
+
+		if !bytes.Equal(got, test.chain) {
+			t.Errorf("GetByHash = %v, want %v", got, test.chain)
+		}
+	}
+}
+
+func TestIssuanceChainHashLen(t *testing.T) {
+	want := sha256.Size
+	tests := []struct {
+		chain []byte
+	}{
+		{readTestData(t, "leaf00.chain")},
+		{readTestData(t, "leaf01.chain")},
+		{readTestData(t, "leaf02.chain")},
+		{nil},
+	}
+
+	for _, test := range tests {
+		got := len(issuanceChainHash(test.chain))
+		if got != want {
+			t.Errorf("len(issuanceChainHash(%v)) = %d, want %d", test.chain, got, want)
+		}
+	}
+}
+
+func readTestData(t *testing.T, filename string) []byte {
+	t.Helper()
+
+	data, err := os.ReadFile("../testdata/" + filename)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	return data
+}
+
+type fakeIssuanceChainStorage struct {
+	chains sync.Map
+}
+
+func (s *fakeIssuanceChainStorage) FindByKey(_ context.Context, key []byte) ([]byte, error) {
+	val, _ := s.chains.Load(string(key))
+	chain, _ := val.([]byte)
+	return chain, nil
+}
+
+func (s *fakeIssuanceChainStorage) Add(_ context.Context, key []byte, chain []byte) error {
+	s.chains.Store(string(key), chain)
+	return nil
+}
+
+type fakeIssuanceChainCache struct {
+	chains sync.Map
+}
+
+func (c *fakeIssuanceChainCache) Get(_ context.Context, key []byte) ([]byte, error) {
+	val, _ := c.chains.Load(string(key))
+	chain, _ := val.([]byte)
+	return chain, nil
+}
+
+func (c *fakeIssuanceChainCache) Set(_ context.Context, key []byte, chain []byte) error {
+	c.chains.Store(string(key), chain)
+	return nil
+}