From 84c2ec076201697ad05e5315444812f824dad56b Mon Sep 17 00:00:00 2001 From: Patrick Zheng Date: Thu, 26 Sep 2024 09:57:24 +0800 Subject: [PATCH] feat: crl cache (#462) Signed-off-by: Patrick Zheng --- dir/fs.go | 7 + dir/fs_test.go | 11 ++ dir/path.go | 41 +++- dir/path_test.go | 29 ++- go.mod | 2 +- go.sum | 4 +- internal/file/file.go | 34 ++++ internal/file/file_test.go | 61 ++++-- verifier/crl/crl.go | 171 ++++++++++++++++ verifier/crl/crl_test.go | 395 +++++++++++++++++++++++++++++++++++++ verifier/verifier.go | 9 +- 11 files changed, 736 insertions(+), 28 deletions(-) create mode 100644 verifier/crl/crl.go create mode 100644 verifier/crl/crl_test.go diff --git a/dir/fs.go b/dir/fs.go index 444179ce..93da420e 100644 --- a/dir/fs.go +++ b/dir/fs.go @@ -58,3 +58,10 @@ func ConfigFS() SysFS { func PluginFS() SysFS { return NewSysFS(filepath.Join(userLibexecDirPath(), PathPlugins)) } + +// CacheFS is the cache SysFS. +// +// To get the root of crl file cache, use `CacheFS().SysFS(PathCRLCache)`. +func CacheFS() SysFS { + return NewSysFS(userCacheDirPath()) +} diff --git a/dir/fs_test.go b/dir/fs_test.go index 6488e5de..c9129f01 100644 --- a/dir/fs_test.go +++ b/dir/fs_test.go @@ -71,3 +71,14 @@ func TestPluginFS(t *testing.T) { t.Fatalf(`SysPath() failed. got: %q, want: %q`, path, filepath.Join(userLibexecDirPath(), PathPlugins, "plugin")) } } + +func TestCRLFileCacheFS(t *testing.T) { + cacheFS := CacheFS() + path, err := cacheFS.SysPath(PathCRLCache) + if err != nil { + t.Fatalf("SysPath() failed. err = %v", err) + } + if path != filepath.Join(UserCacheDir, PathCRLCache) { + t.Fatalf(`SysPath() failed. got: %q, want: %q`, path, UserConfigDir) + } +} diff --git a/dir/path.go b/dir/path.go index a08a9a24..7420071e 100644 --- a/dir/path.go +++ b/dir/path.go @@ -12,7 +12,7 @@ // limitations under the License. // Package dir implements Notation directory structure. -// [directory spec]: https://github.com/notaryproject/notation/blob/main/specs/directory.md +// [directory spec]: https://notaryproject.dev/docs/user-guides/how-to/directory-structure/ // // Example: // @@ -31,7 +31,7 @@ // - Set custom configurations directory: // dir.UserConfigDir = '/path/to/configurations/' // -// Only user level directory is supported for RC.1, and system level directory +// Only user level directory is supported, and system level directory // may be added later. package dir @@ -44,6 +44,7 @@ import ( var ( UserConfigDir string // Absolute path of user level {NOTATION_CONFIG} UserLibexecDir string // Absolute path of user level {NOTATION_LIBEXEC} + UserCacheDir string // Absolute path of user level {NOTATION_CACHE} ) const ( @@ -65,8 +66,6 @@ const ( PathOCITrustPolicy = "trustpolicy.oci.json" // PathBlobTrustPolicy is the Blob trust policy file relative path. PathBlobTrustPolicy = "trustpolicy.blob.json" - // PathPlugins is the plugins directory relative path. - PathPlugins = "plugins" // LocalKeysDir is the directory name for local key relative path. LocalKeysDir = "localkeys" // LocalCertificateExtension defines the extension of the certificate files. @@ -77,7 +76,24 @@ const ( TrustStoreDir = "truststore" ) -var userConfigDir = os.UserConfigDir // for unit test +// The relative path to {NOTATION_LIBEXEC} +const ( + // PathPlugins is the plugins directory relative path. + PathPlugins = "plugins" +) + +// The relative path to {NOTATION_CACHE} +const ( + // PathCRLCache is the crl file cache directory relative path. + PathCRLCache = "crl" +) + +// for unit tests +var ( + userConfigDir = os.UserConfigDir + + userCacheDir = os.UserCacheDir +) // userConfigDirPath returns the user level {NOTATION_CONFIG} path. func userConfigDirPath() string { @@ -103,6 +119,21 @@ func userLibexecDirPath() string { return UserLibexecDir } +// userCacheDirPath returns the user level {NOTATION_CACHE} path. +func userCacheDirPath() string { + if UserCacheDir == "" { + userDir, err := userCacheDir() + if err != nil { + // fallback to current directory + UserCacheDir = filepath.Join("."+notation, "cache") + return UserCacheDir + } + // set user cache + UserCacheDir = filepath.Join(userDir, notation) + } + return UserCacheDir +} + // LocalKeyPath returns the local key and local cert relative paths. func LocalKeyPath(name string) (keyPath, certPath string) { basePath := path.Join(LocalKeysDir, name) diff --git a/dir/path_test.go b/dir/path_test.go index ceeafc3f..ee90f2dc 100644 --- a/dir/path_test.go +++ b/dir/path_test.go @@ -15,20 +15,22 @@ package dir import ( "os" + "path/filepath" "testing" ) -func mockGetUserConfig() (string, error) { +func mockUserPath() (string, error) { return "/path/", nil } func setup() { UserConfigDir = "" UserLibexecDir = "" + UserCacheDir = "" } func Test_UserConfigDirPath(t *testing.T) { - userConfigDir = mockGetUserConfig + userConfigDir = mockUserPath setup() got := userConfigDirPath() if got != "/path/notation" { @@ -39,16 +41,22 @@ func Test_UserConfigDirPath(t *testing.T) { func Test_NoHomeVariable(t *testing.T) { t.Setenv("HOME", "") t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("XDG_CACHE_HOME", "") setup() userConfigDir = os.UserConfigDir got := userConfigDirPath() if got != ".notation" { - t.Fatalf(`UserConfigDirPath() = %q, want ".notation"`, UserConfigDir) + t.Fatalf(`userConfigDirPath() = %q, want ".notation"`, got) + } + got = userCacheDirPath() + want := filepath.Join("."+notation, "cache") + if got != want { + t.Fatalf(`userCacheDirPath() = %q, want %q`, got, want) } } func Test_UserLibexecDirPath(t *testing.T) { - userConfigDir = mockGetUserConfig + userConfigDir = mockUserPath setup() got := userLibexecDirPath() if got != "/path/notation" { @@ -56,8 +64,17 @@ func Test_UserLibexecDirPath(t *testing.T) { } } +func Test_UserCacheDirPath(t *testing.T) { + userCacheDir = mockUserPath + setup() + got := userCacheDirPath() + if got != "/path/notation" { + t.Fatalf(`UserCacheDirPath() = %q, want "/path/notation"`, got) + } +} + func TestLocalKeyPath(t *testing.T) { - userConfigDir = mockGetUserConfig + userConfigDir = mockUserPath setup() _ = userConfigDirPath() _ = userLibexecDirPath() @@ -71,7 +88,7 @@ func TestLocalKeyPath(t *testing.T) { } func TestX509TrustStoreDir(t *testing.T) { - userConfigDir = mockGetUserConfig + userConfigDir = mockUserPath setup() _ = userConfigDirPath() _ = userLibexecDirPath() diff --git a/go.mod b/go.mod index 8f52c3d0..144b4ef0 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.22.0 require ( github.com/go-ldap/ldap/v3 v3.4.8 - github.com/notaryproject/notation-core-go v1.1.1-0.20240918011623-695ea0c1ad1f + github.com/notaryproject/notation-core-go v1.1.1-0.20240920045731-0786f51de737 github.com/notaryproject/notation-plugin-framework-go v1.0.0 github.com/notaryproject/tspclient-go v0.2.0 github.com/opencontainers/go-digest v1.0.0 diff --git a/go.sum b/go.sum index 4278373e..27032854 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= -github.com/notaryproject/notation-core-go v1.1.1-0.20240918011623-695ea0c1ad1f h1:TmwJtM3AZ7iQ1LJEbHRPAMRw4hA52/AbVrllSVjCNP0= -github.com/notaryproject/notation-core-go v1.1.1-0.20240918011623-695ea0c1ad1f/go.mod h1:+6AOh41JPrnVLbW/19SJqdhVHwKgIINBO/np0e7nXJA= +github.com/notaryproject/notation-core-go v1.1.1-0.20240920045731-0786f51de737 h1:Hp93KBCABE29+6zdS0GTg0T1SXj6qGatJyN1JMvTQqk= +github.com/notaryproject/notation-core-go v1.1.1-0.20240920045731-0786f51de737/go.mod h1:b/70rA4OgOHlg0A7pb8zTWKJadFO6781zS3a37KHEJQ= github.com/notaryproject/notation-plugin-framework-go v1.0.0 h1:6Qzr7DGXoCgXEQN+1gTZWuJAZvxh3p8Lryjn5FaLzi4= github.com/notaryproject/notation-plugin-framework-go v1.0.0/go.mod h1:RqWSrTOtEASCrGOEffq0n8pSg2KOgKYiWqFWczRSics= github.com/notaryproject/tspclient-go v0.2.0 h1:g/KpQGmyk/h7j60irIRG1mfWnibNOzJ8WhLqAzuiQAQ= diff --git a/internal/file/file.go b/internal/file/file.go index 920fa640..c6e95849 100644 --- a/internal/file/file.go +++ b/internal/file/file.go @@ -15,6 +15,7 @@ package file import ( "errors" + "fmt" "io" "io/fs" "os" @@ -23,6 +24,11 @@ import ( "strings" ) +const ( + // tempFileNamePrefix is the prefix of the temporary file + tempFileNamePrefix = "notation-*" +) + // ErrNotRegularFile is returned when the file is not an regular file. var ErrNotRegularFile = errors.New("not regular file") @@ -110,3 +116,31 @@ func CopyDirToDir(src, dst string) error { func TrimFileExtension(fileName string) string { return strings.TrimSuffix(fileName, filepath.Ext(fileName)) } + +// WriteFile writes content to a temporary file and moves it to path. +// If path already exists and is a file, WriteFile overwrites it. +func WriteFile(path string, content []byte) (writeErr error) { + tempFile, err := os.CreateTemp("", tempFileNamePrefix) + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer func() { + // remove the temp file in case of error + if writeErr != nil { + tempFile.Close() + os.Remove(tempFile.Name()) + } + }() + + if _, err := tempFile.Write(content); err != nil { + return fmt.Errorf("failed to write content to temp file: %w", err) + } + + // close before moving + if err := tempFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + // rename is atomic on UNIX-like platforms + return os.Rename(tempFile.Name(), path) +} diff --git a/internal/file/file_test.go b/internal/file/file_test.go index a108d0da..306a0a5b 100644 --- a/internal/file/file_test.go +++ b/internal/file/file_test.go @@ -18,6 +18,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" ) @@ -26,7 +27,10 @@ func TestCopyToDir(t *testing.T) { tempDir := t.TempDir() data := []byte("data") filename := filepath.Join(tempDir, "a", "file.txt") - if err := writeFile(filename, data); err != nil { + if err := os.MkdirAll(filepath.Dir(filename), 0700); err != nil { + t.Fatal(err) + } + if err := WriteFile(filename, data); err != nil { t.Fatal(err) } @@ -45,7 +49,10 @@ func TestCopyToDir(t *testing.T) { destDir := t.TempDir() data := []byte("data") filename := filepath.Join(tempDir, "a", "file.txt") - if err := writeFile(filename, data); err != nil { + if err := os.MkdirAll(filepath.Dir(filename), 0700); err != nil { + t.Fatal(err) + } + if err := WriteFile(filename, data); err != nil { t.Fatal(err) } @@ -77,7 +84,10 @@ func TestCopyToDir(t *testing.T) { data := []byte("data") // prepare file filename := filepath.Join(tempDir, "a", "file.txt") - if err := writeFile(filename, data); err != nil { + if err := os.MkdirAll(filepath.Dir(filename), 0700); err != nil { + t.Fatal(err) + } + if err := WriteFile(filename, data); err != nil { t.Fatal(err) } // forbid reading @@ -100,7 +110,10 @@ func TestCopyToDir(t *testing.T) { data := []byte("data") // prepare file filename := filepath.Join(tempDir, "a", "file.txt") - if err := writeFile(filename, data); err != nil { + if err := os.MkdirAll(filepath.Dir(filename), 0700); err != nil { + t.Fatal(err) + } + if err := WriteFile(filename, data); err != nil { t.Fatal(err) } // forbid dest directory operation @@ -123,7 +136,10 @@ func TestCopyToDir(t *testing.T) { data := []byte("data") // prepare file filename := filepath.Join(tempDir, "a", "file.txt") - if err := writeFile(filename, data); err != nil { + if err := os.MkdirAll(filepath.Dir(filename), 0700); err != nil { + t.Fatal(err) + } + if err := WriteFile(filename, data); err != nil { t.Fatal(err) } // forbid writing to destTempDir @@ -140,7 +156,10 @@ func TestCopyToDir(t *testing.T) { tempDir := t.TempDir() data := []byte("data") filename := filepath.Join(tempDir, "a", "file.txt") - if err := writeFile(filename, data); err != nil { + if err := os.MkdirAll(filepath.Dir(filename), 0700); err != nil { + t.Fatal(err) + } + if err := WriteFile(filename, data); err != nil { t.Fatal(err) } @@ -161,6 +180,29 @@ func TestFileNameWithoutExtension(t *testing.T) { } } +func TestWriteFile(t *testing.T) { + tempDir := t.TempDir() + content := []byte("test WriteFile") + + t.Run("permission denied", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping test on Windows") + } + err := os.Chmod(tempDir, 0) + if err != nil { + t.Fatal(err) + } + err = WriteFile(filepath.Join(tempDir, "testFile"), content) + if err == nil || !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("expected permission denied error, but got %s", err) + } + err = os.Chmod(tempDir, 0700) + if err != nil { + t.Fatal(err) + } + }) +} + func validFileContent(t *testing.T, filename string, content []byte) { b, err := os.ReadFile(filename) if err != nil { @@ -170,10 +212,3 @@ func validFileContent(t *testing.T, filename string, content []byte) { t.Fatal("file content is not correct") } } - -func writeFile(path string, data []byte) error { - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return err - } - return os.WriteFile(path, data, 0600) -} diff --git a/verifier/crl/crl.go b/verifier/crl/crl.go new file mode 100644 index 00000000..9ebd16db --- /dev/null +++ b/verifier/crl/crl.go @@ -0,0 +1,171 @@ +// Copyright The Notary Project Authors. +// 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 crl provides functionalities for crl revocation check. +package crl + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "time" + + corecrl "github.com/notaryproject/notation-core-go/revocation/crl" + "github.com/notaryproject/notation-go/internal/file" + "github.com/notaryproject/notation-go/log" +) + +// FileCache implements corecrl.Cache. +// +// Key: url of the CRL. +// +// Value: corecrl.Bundle. +// +// This cache builds on top of the UNIX file system to leverage the file system's +// atomic operations. The `rename` and `remove` operations will unlink the old +// file but keep the inode and file descriptor for existing processes to access +// the file. The old inode will be dereferenced when all processes close the old +// file descriptor. Additionally, the operations are proven to be atomic on +// UNIX-like platforms, so there is no need to handle file locking. +// +// NOTE: For Windows, the `open`, `rename` and `remove` operations need file +// locking to ensure atomicity. The current implementation does not handle +// file locking, so the concurrent write from multiple processes may be failed. +// Please do not use this cache in a multi-process environment on Windows. +type FileCache struct { + // root is the root directory of the cache + root string +} + +// fileCacheContent is the actual content saved in a FileCache +type fileCacheContent struct { + // BaseCRL is the ASN.1 encoded base CRL + BaseCRL []byte `json:"baseCRL"` + + // DeltaCRL is the ASN.1 encoded delta CRL + DeltaCRL []byte `json:"deltaCRL,omitempty"` +} + +// NewFileCache creates a FileCache with root as the root directory +// +// An example for root is `dir.CacheFS().SysPath(dir.PathCRLCache)` +func NewFileCache(root string) (*FileCache, error) { + if err := os.MkdirAll(root, 0700); err != nil { + return nil, fmt.Errorf("failed to create crl file cache: %w", err) + } + return &FileCache{ + root: root, + }, nil +} + +// Get retrieves CRL bundle from c given url as key. If the key does not exist +// or the content has expired, corecrl.ErrCacheMiss is returned. +func (c *FileCache) Get(ctx context.Context, url string) (*corecrl.Bundle, error) { + logger := log.GetLogger(ctx) + logger.Debugf("Retrieving crl bundle from file cache with key %q ...", url) + + // get content from file cache + contentBytes, err := os.ReadFile(filepath.Join(c.root, c.fileName(url))) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + logger.Debugf("CRL file cache miss. Key %q does not exist", url) + return nil, corecrl.ErrCacheMiss + } + return nil, fmt.Errorf("failed to get crl bundle from file cache with key %q: %w", url, err) + } + + // decode content to crl Bundle + var content fileCacheContent + if err := json.Unmarshal(contentBytes, &content); err != nil { + return nil, fmt.Errorf("failed to decode file retrieved from file cache: %w", err) + } + var bundle corecrl.Bundle + bundle.BaseCRL, err = x509.ParseRevocationList(content.BaseCRL) + if err != nil { + return nil, fmt.Errorf("failed to parse base CRL of file retrieved from file cache: %w", err) + } + if content.DeltaCRL != nil { + bundle.DeltaCRL, err = x509.ParseRevocationList(content.DeltaCRL) + if err != nil { + return nil, fmt.Errorf("failed to parse delta CRL of file retrieved from file cache: %w", err) + } + } + + // check expiry + if err := checkExpiry(ctx, bundle.BaseCRL.NextUpdate); err != nil { + return nil, err + } + if bundle.DeltaCRL != nil { + if err := checkExpiry(ctx, bundle.DeltaCRL.NextUpdate); err != nil { + return nil, err + } + } + + return &bundle, nil +} + +// Set stores the CRL bundle in c with url as key. +func (c *FileCache) Set(ctx context.Context, url string, bundle *corecrl.Bundle) error { + logger := log.GetLogger(ctx) + logger.Debugf("Storing crl bundle to file cache with key %q ...", url) + + if bundle == nil { + return errors.New("failed to store crl bundle in file cache: bundle cannot be nil") + } + if bundle.BaseCRL == nil { + return errors.New("failed to store crl bundle in file cache: bundle BaseCRL cannot be nil") + } + + // actual content to be saved in the cache + content := fileCacheContent{ + BaseCRL: bundle.BaseCRL.Raw, + } + if bundle.DeltaCRL != nil { + content.DeltaCRL = bundle.DeltaCRL.Raw + } + contentBytes, err := json.Marshal(content) + if err != nil { + return fmt.Errorf("failed to store crl bundle in file cache: %w", err) + } + if err := file.WriteFile(filepath.Join(c.root, c.fileName(url)), contentBytes); err != nil { + return fmt.Errorf("failed to store crl bundle in file cache: %w", err) + } + return nil +} + +// fileName returns the filename of the content stored in c +func (c *FileCache) fileName(url string) string { + hash := sha256.Sum256([]byte(url)) + return hex.EncodeToString(hash[:]) +} + +// checkExpiry returns nil when nextUpdate is bounded before current time +func checkExpiry(ctx context.Context, nextUpdate time.Time) error { + logger := log.GetLogger(ctx) + + if nextUpdate.IsZero() { + return errors.New("crl bundle retrieved from file cache does not contain valid NextUpdate") + } + if time.Now().After(nextUpdate) { + logger.Debugf("CRL bundle retrieved from file cache has expired at %s", nextUpdate) + return corecrl.ErrCacheMiss + } + return nil +} diff --git a/verifier/crl/crl_test.go b/verifier/crl/crl_test.go new file mode 100644 index 00000000..6eafca3c --- /dev/null +++ b/verifier/crl/crl_test.go @@ -0,0 +1,395 @@ +// Copyright The Notary Project Authors. +// 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 crl + +import ( + "context" + "crypto/rand" + "crypto/x509" + "encoding/json" + "errors" + "math/big" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + "time" + + corecrl "github.com/notaryproject/notation-core-go/revocation/crl" + "github.com/notaryproject/notation-core-go/testhelper" +) + +func TestCache(t *testing.T) { + t.Run("file cache implement Cache interface", func(t *testing.T) { + root := t.TempDir() + var coreCache corecrl.Cache + var err error + coreCache, err = NewFileCache(root) + if err != nil { + t.Fatal(err) + } + if _, ok := coreCache.(*FileCache); !ok { + t.Fatal("FileCache does not implement coreCache") + } + }) +} + +func TestFileCache(t *testing.T) { + now := time.Now() + certChain := testhelper.GetRevokableRSAChainWithRevocations(2, false, true) + crlBytes, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + NextUpdate: now.Add(time.Hour), + }, certChain[1].Cert, certChain[1].PrivateKey) + if err != nil { + t.Fatal(err) + } + baseCRL, err := x509.ParseRevocationList(crlBytes) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + root := t.TempDir() + cache, err := NewFileCache(root) + t.Run("NewFileCache", func(t *testing.T) { + if err != nil { + t.Fatalf("expected no error, but got %v", err) + } + if cache.root != root { + t.Fatalf("expected root %v, but got %v", root, cache.root) + } + }) + + key := "http://example.com" + t.Run("comformance", func(t *testing.T) { + bundle := &corecrl.Bundle{BaseCRL: baseCRL} + if err := cache.Set(ctx, key, bundle); err != nil { + t.Fatal(err) + } + retrievedBundle, err := cache.Get(ctx, key) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(retrievedBundle.BaseCRL, bundle.BaseCRL) { + t.Fatalf("expected BaseCRL %+v, but got %+v", bundle.BaseCRL, retrievedBundle.BaseCRL) + } + + if bundle.DeltaCRL != nil { + t.Fatalf("expected DeltaCRL to be nil, but got %+v", retrievedBundle.DeltaCRL) + } + }) + + t.Run("comformance with delta crl", func(t *testing.T) { + crlBytes, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(2), + NextUpdate: now.Add(time.Hour), + }, certChain[1].Cert, certChain[1].PrivateKey) + if err != nil { + t.Fatal(err) + } + deltaCRL, err := x509.ParseRevocationList(crlBytes) + if err != nil { + t.Fatal(err) + } + bundle := &corecrl.Bundle{BaseCRL: baseCRL, DeltaCRL: deltaCRL} + if err := cache.Set(ctx, key, bundle); err != nil { + t.Fatal(err) + } + retrievedBundle, err := cache.Get(ctx, key) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(retrievedBundle.BaseCRL, bundle.BaseCRL) { + t.Fatalf("expected BaseCRL %+v, but got %+v", bundle.BaseCRL, retrievedBundle.BaseCRL) + } + + if !reflect.DeepEqual(retrievedBundle.DeltaCRL, bundle.DeltaCRL) { + t.Fatalf("expected DeltaCRL %+v, but got %+v", bundle.DeltaCRL, retrievedBundle.DeltaCRL) + } + }) +} + +func TestNewFileCacheFailed(t *testing.T) { + tempDir := t.TempDir() + t.Run("without permission to create cache directory", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping test on Windows") + } + + if err := os.Chmod(tempDir, 0); err != nil { + t.Fatal(err) + } + root := filepath.Join(tempDir, "test") + _, err := NewFileCache(root) + if !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("expected permission denied error, but got %v", err) + } + // restore permission + if err := os.Chmod(tempDir, 0755); err != nil { + t.Fatalf("failed to change permission: %v", err) + } + }) +} + +func TestGetFailed(t *testing.T) { + tempDir := t.TempDir() + cache, err := NewFileCache(tempDir) + if err != nil { + t.Fatal(err) + } + + t.Run("key does not exist", func(t *testing.T) { + _, err := cache.Get(context.Background(), "nonExistKey") + if !errors.Is(err, corecrl.ErrCacheMiss) { + t.Fatalf("expected ErrCacheMiss, but got %v", err) + } + }) + + invalidFile := filepath.Join(tempDir, cache.fileName("invalid")) + if err := os.WriteFile(invalidFile, []byte("invalid"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + t.Run("no permission to read file", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping test on Windows") + } + + if err := os.Chmod(invalidFile, 0); err != nil { + t.Fatal(err) + } + _, err := cache.Get(context.Background(), "invalid") + if err == nil || !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("expected permission denied error, but got %v", err) + } + // restore permission + if err := os.Chmod(invalidFile, 0755); err != nil { + t.Fatal(err) + } + }) + + t.Run("invalid content", func(t *testing.T) { + _, err := cache.Get(context.Background(), "invalid") + expectedErrMsg := "failed to decode file retrieved from file cache: invalid character 'i' looking for beginning of value" + if err == nil || err.Error() != expectedErrMsg { + t.Fatalf("expected %s, but got %v", expectedErrMsg, err) + } + }) + + now := time.Now() + certChain := testhelper.GetRevokableRSAChainWithRevocations(2, false, true) + crlBytes, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + }, certChain[1].Cert, certChain[1].PrivateKey) + if err != nil { + t.Fatalf("failed to create base CRL: %v", err) + } + baseCRL, err := x509.ParseRevocationList(crlBytes) + if err != nil { + t.Fatalf("failed to parse base CRL: %v", err) + } + + t.Run("empty RawBaseCRL of content", func(t *testing.T) { + content := fileCacheContent{ + BaseCRL: []byte{}, + } + b, err := json.Marshal(content) + if err != nil { + t.Fatal(err) + } + invalidBundleFile := filepath.Join(tempDir, cache.fileName("invalidBundle")) + if err := os.WriteFile(invalidBundleFile, b, 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + _, err = cache.Get(context.Background(), "invalidBundle") + expectedErrMsg := "failed to parse base CRL of file retrieved from file cache: x509: malformed crl" + if err == nil || err.Error() != expectedErrMsg { + t.Fatalf("expected %s, but got %v", expectedErrMsg, err) + } + }) + + t.Run("invalid RawBaseCRL of content", func(t *testing.T) { + content := fileCacheContent{ + BaseCRL: []byte("invalid"), + } + b, err := json.Marshal(content) + if err != nil { + t.Fatal(err) + } + invalidBundleFile := filepath.Join(tempDir, cache.fileName("invalidBundle")) + if err := os.WriteFile(invalidBundleFile, b, 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + _, err = cache.Get(context.Background(), "invalidBundle") + expectedErrMsg := "failed to parse base CRL of file retrieved from file cache: x509: malformed crl" + if err == nil || err.Error() != expectedErrMsg { + t.Fatalf("expected %s, but got %v", expectedErrMsg, err) + } + }) + + t.Run("invalid RawDeltaCRL of content", func(t *testing.T) { + content := fileCacheContent{ + BaseCRL: baseCRL.Raw, + DeltaCRL: []byte("invalid"), + } + b, err := json.Marshal(content) + if err != nil { + t.Fatal(err) + } + invalidBundleFile := filepath.Join(tempDir, cache.fileName("invalidBundle")) + if err := os.WriteFile(invalidBundleFile, b, 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + _, err = cache.Get(context.Background(), "invalidBundle") + expectedErrMsg := "failed to parse delta CRL of file retrieved from file cache: x509: malformed crl" + if err == nil || err.Error() != expectedErrMsg { + t.Fatalf("expected %s, but got %v", expectedErrMsg, err) + } + }) + + t.Run("bundle with invalid NextUpdate", func(t *testing.T) { + ctx := context.Background() + expiredBundle := &corecrl.Bundle{BaseCRL: baseCRL} + if err := cache.Set(ctx, "expiredKey", expiredBundle); err != nil { + t.Fatal(err) + } + _, err = cache.Get(ctx, "expiredKey") + expectedErrMsg := "crl bundle retrieved from file cache does not contain valid NextUpdate" + if err == nil || err.Error() != expectedErrMsg { + t.Fatalf("expected %s, but got %v", expectedErrMsg, err) + } + }) + + crlBytes, err = x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + NextUpdate: now.Add(-time.Hour), + }, certChain[1].Cert, certChain[1].PrivateKey) + if err != nil { + t.Fatalf("failed to create base CRL: %v", err) + } + expiredBaseCRL, err := x509.ParseRevocationList(crlBytes) + if err != nil { + t.Fatalf("failed to parse base CRL: %v", err) + } + t.Run("base crl in cache has expired", func(t *testing.T) { + ctx := context.Background() + expiredBundle := &corecrl.Bundle{BaseCRL: expiredBaseCRL} + if err := cache.Set(ctx, "expiredKey", expiredBundle); err != nil { + t.Fatal(err) + } + _, err = cache.Get(ctx, "expiredKey") + if !errors.Is(err, corecrl.ErrCacheMiss) { + t.Fatalf("expected ErrCacheMiss, but got %v", err) + } + }) + + t.Run("delta crl in cache has expired", func(t *testing.T) { + ctx := context.Background() + crlBytes, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + NextUpdate: now.Add(time.Hour), + }, certChain[1].Cert, certChain[1].PrivateKey) + if err != nil { + t.Fatalf("failed to create base CRL: %v", err) + } + baseCRL, err := x509.ParseRevocationList(crlBytes) + if err != nil { + t.Fatalf("failed to parse base CRL: %v", err) + } + crlBytes, err = x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + NextUpdate: now.Add(-time.Hour), + }, certChain[1].Cert, certChain[1].PrivateKey) + if err != nil { + t.Fatalf("failed to create base CRL: %v", err) + } + expiredDeltaCRL, err := x509.ParseRevocationList(crlBytes) + if err != nil { + t.Fatalf("failed to parse base CRL: %v", err) + } + expiredBundle := &corecrl.Bundle{BaseCRL: baseCRL, DeltaCRL: expiredDeltaCRL} + if err := cache.Set(ctx, "expiredKey", expiredBundle); err != nil { + t.Fatal(err) + } + _, err = cache.Get(ctx, "expiredKey") + if !errors.Is(err, corecrl.ErrCacheMiss) { + t.Fatalf("expected ErrCacheMiss, but got %v", err) + } + }) +} + +func TestSetFailed(t *testing.T) { + tempDir := t.TempDir() + cache, err := NewFileCache(tempDir) + if err != nil { + t.Fatal(err) + } + + now := time.Now() + certChain := testhelper.GetRevokableRSAChainWithRevocations(2, false, true) + crlBytes, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + NextUpdate: now.Add(time.Hour), + }, certChain[1].Cert, certChain[1].PrivateKey) + if err != nil { + t.Fatal(err) + } + baseCRL, err := x509.ParseRevocationList(crlBytes) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + key := "testKey" + + t.Run("nil bundle", func(t *testing.T) { + err := cache.Set(ctx, key, nil) + expectedErrMsg := "failed to store crl bundle in file cache: bundle cannot be nil" + if err == nil || err.Error() != expectedErrMsg { + t.Fatalf("expected %s, but got %v", expectedErrMsg, err) + } + }) + + t.Run("nil bundle BaseCRL", func(t *testing.T) { + bundle := &corecrl.Bundle{} + err := cache.Set(ctx, key, bundle) + expectedErrMsg := "failed to store crl bundle in file cache: bundle BaseCRL cannot be nil" + if err == nil || err.Error() != expectedErrMsg { + t.Fatalf("expected %s, but got %v", expectedErrMsg, err) + } + }) + + t.Run("failed to write into cache due to permission denied", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping test on Windows") + } + + if err := os.Chmod(tempDir, 0); err != nil { + t.Fatal(err) + } + bundle := &corecrl.Bundle{BaseCRL: baseCRL} + err := cache.Set(ctx, key, bundle) + if err == nil || !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("expected permission denied error, but got %v", err) + } + // restore permission + if err := os.Chmod(tempDir, 0755); err != nil { + t.Fatalf("failed to change permission: %v", err) + } + }) +} diff --git a/verifier/verifier.go b/verifier/verifier.go index 0b62904e..17e5cfb2 100644 --- a/verifier/verifier.go +++ b/verifier/verifier.go @@ -816,7 +816,14 @@ func revocationFinalResult(certResults []*revocationresult.CertRevocationResult, } for _, serverResult := range certResult.ServerResults { if serverResult.Error != nil { - // log the revocation error + // log individual server errors + if certResult.RevocationMethod == revocationresult.RevocationMethodOCSPFallbackCRL && serverResult.RevocationMethod == revocationresult.RevocationMethodOCSP { + // when the final revocation method is OCSPFallbackCRL, + // the OCSP server results should not be logged as an error + // since the CRL revocation check can succeed. + logger.Debugf("Certificate #%d in chain with subject %v encountered an error for revocation method %s at URL %q: %v", (i + 1), cert.Subject.String(), revocationresult.RevocationMethodOCSP, serverResult.Server, serverResult.Error) + continue + } logger.Errorf("Certificate #%d in chain with subject %v encountered an error for revocation method %s at URL %q: %v", (i + 1), cert.Subject.String(), serverResult.RevocationMethod, serverResult.Server, serverResult.Error) } }