diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 53a269e..43aa3bd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,12 +9,17 @@ on: - '**' workflow_dispatch: +permissions: + contents: read + jobs: test: runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@v5 @@ -30,7 +35,7 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@v8 with: - version: v2.1.6 + version: v2.3.0 - name: Vendorcheck shell: bash diff --git a/.golangci.yaml b/.golangci.yaml index 895705b..a8ff00d 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -2,16 +2,25 @@ version: "2" linters: default: all disable: + - err113 - depguard - lll - musttag + - wsl + - wsl_v5 settings: misspell: locale: US + funlen: + lines: -1 gosec: excludes: - G104 # Errors unhandled - G304 # Potential file inclusion via variable + varnamelen: + ignore-names: + - id + - tc exclusions: presets: - std-error-handling diff --git a/cmd/sunlight-secretmanager/config.go b/cmd/sunlight-secretmanager/config.go new file mode 100644 index 0000000..f164fa3 --- /dev/null +++ b/cmd/sunlight-secretmanager/config.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// config is a subset of Sunlight's Config. We use it to load config information +// about each log. +type config struct { + Logs []logConfig +} + +// logConfig is a subset of Sunlight's LogConfig. We use it to load just the +// info we need about each individual log. +type logConfig struct { + // Name is the unique human-readable identifier of the log. We use it for + // logging purposes. + Name string + // Secret is the path to the file where the sunlight instance expects to + // find this log's secret seed. We write the seed to this path. + Secret string +} + +// loadConfig takes path to a yaml file and returns the seeds in that log file. +// Exported for use in main.go. +func loadConfig(configFile string) (*config, error) { + yml, err := os.ReadFile(configFile) + if err != nil { + return nil, fmt.Errorf("reading config file %q: %w", configFile, err) + } + + var sunlightConfig config + err = yaml.Unmarshal(yml, &sunlightConfig) + if err != nil { + return nil, fmt.Errorf("parsing config file %q: %w", configFile, err) + } + + if len(sunlightConfig.Logs) == 0 { + return nil, fmt.Errorf("no logs found in config file %q", configFile) + } + + for _, log := range sunlightConfig.Logs { + if log.Name == "" || log.Secret == "" { + return nil, fmt.Errorf("incomplete config for log %q in config file %q", log.Name, configFile) + } + } + + return &sunlightConfig, nil +} diff --git a/cmd/sunlight-secretmanager/config_test.go b/cmd/sunlight-secretmanager/config_test.go new file mode 100644 index 0000000..59828ed --- /dev/null +++ b/cmd/sunlight-secretmanager/config_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestLoadConfig(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + input string + want *config + wantErr string + }{ + { + name: "invalid path", + input: "", + want: nil, + wantErr: "reading config file", + }, + { + name: "invalid yaml", + input: "invalid.yaml", + want: nil, + wantErr: "parsing config file", + }, + { + name: "empty", + input: "empty.yaml", + want: nil, + wantErr: "no logs found in config file", // an empty file is valid yaml + }, + { + name: "no logs", + input: "nologs.yaml", + want: nil, + wantErr: "no logs found in config file", + }, + { + name: "incomplete", + input: "missingkeys.yaml", + want: nil, + wantErr: "incomplete config for log", + }, + { + name: "happy path", + input: "happy.yaml", + want: &config{ + Logs: []logConfig{ + { + Name: "test.tld/shard1", + Secret: "/path/to/shard1.seed", + }, + { + Name: "test.tld/shard2", + Secret: "/path/to/shard2.seed", + }, + }, + }, + wantErr: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := loadConfig(filepath.Join("testdata", tc.input)) + + if tc.wantErr != "" { //nolint:nestif + if err == nil { + t.Errorf("loadConfig() = %#v, but want %q", got, tc.wantErr) + } else if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("loadConfig() = %q, but want %q", err, tc.wantErr) + } + } else { + if err != nil { + t.Errorf("loadConfig() = %q, but want %#v", err, tc.want) + } else if !reflect.DeepEqual(got, tc.want) { + t.Errorf("loadConfig() = %#v, but want %#v", got, tc.want) + } + } + }) + } +} diff --git a/cmd/sunlight-secretmanager/filesystem.go b/cmd/sunlight-secretmanager/filesystem.go new file mode 100644 index 0000000..05a7ca6 --- /dev/null +++ b/cmd/sunlight-secretmanager/filesystem.go @@ -0,0 +1,47 @@ +package main + +import ( + "fmt" + "os" + "syscall" +) + +func writeFile(path string, content []byte, fsType int64) error { + // We currently assume that the directory we are trying to write to already exists. + file, err := os.OpenFile( + path, + // The combination of O_CREATE and O_EXCL means this operation will fail + // if the file already exists. + os.O_RDWR|os.O_CREATE|os.O_EXCL, + // Setting nolint here because file permissions octal value isn't a magic number. + //nolint: mnd + 0o400, + ) + if err != nil { + return fmt.Errorf("creating file at path %q: %w", path, err) + } + defer file.Close() + + var statfs syscall.Statfs_t + err = syscall.Fstatfs(int(file.Fd()), &statfs) + if err != nil { + _ = os.Remove(file.Name()) + + return fmt.Errorf("getting filesystem info at path %q: %w", path, err) + } + + if statfs.Type != fsType { + _ = os.Remove(file.Name()) + + return fmt.Errorf("filesystem at path %q has type %v, but we require %v", path, statfs.Type, fsType) + } + + _, err = file.Write(content) + if err != nil { + _ = os.Remove(file.Name()) + + return fmt.Errorf("writing to file at path %q: %w", path, err) + } + + return nil +} diff --git a/cmd/sunlight-secretmanager/filesystem_test.go b/cmd/sunlight-secretmanager/filesystem_test.go new file mode 100644 index 0000000..bebafe9 --- /dev/null +++ b/cmd/sunlight-secretmanager/filesystem_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteFile(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + + // Do some setup for the "already exists" test. + f, err := os.Create(filepath.Join(tempDir, "already-exists")) + if err != nil { + t.Fatalf("failed to create test setup file: %s", err) + } + err = f.Close() + if err != nil { + t.Fatalf("failed to close test setup file: %s", err) + } + + for _, tc := range []struct { + name string + path string + fsType int64 + wantErr string + }{ + { + name: "already exists", + path: filepath.Join(tempDir, "already-exists"), + fsType: 1, + wantErr: "creating file at path", + }, + { + name: "wrong fstype", + path: filepath.Join(tempDir, "wrong-fstype"), + fsType: 1, + wantErr: "filesystem at path", + }, + { + name: "happy path", + path: filepath.Join(tempDir, "happy"), + fsType: 61267, // The statfs.Type for a normal unix filesystem + wantErr: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := writeFile(tc.path, []byte("hello world"), tc.fsType) + + if tc.wantErr != "" { //nolint:nestif + if err == nil { + t.Errorf("writeFile() = succeeded, but want error %q", tc.wantErr) + } else if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("writeFile() = %#v, but want %q", err, tc.wantErr) + } + } else { + if err != nil { + t.Fatalf("writeFile() = %#v, but want success", err) + } + + got, err := os.ReadFile(tc.path) + if err != nil { + t.Fatalf("failed to re-read file: %s", err) + } + + if string(got) != "hello world" { + t.Errorf("written file contains %q, but want %q", string(got), "hello world") + } + } + }) + } +} diff --git a/cmd/sunlight-secretmanager/main.go b/cmd/sunlight-secretmanager/main.go index ee631a9..87f0187 100755 --- a/cmd/sunlight-secretmanager/main.go +++ b/cmd/sunlight-secretmanager/main.go @@ -1,5 +1,8 @@ -// Sunlight-secretmanager creates new Sunlight CT Log instances, ensuring that -// their signing keys are stored in an appropriate location. +// sunlight-secretmanager ensures that Sunlight CT Log instances have access to +// their secret key material. In particular, it reads the log's config file, +// extracts the paths at which each log in that config expects to find its +// key material, fetches the corresponding secrets from the AWS Secrets Manager +// API, and writes the secrets to the expected location. // // Usage: // @@ -13,45 +16,48 @@ import ( "os" awsconfig "github.com/aws/aws-sdk-go-v2/config" - "github.com/letsencrypt/sunlight-secretmanager/config" - secrets "github.com/letsencrypt/sunlight-secretmanager/secrets" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" ) -// Documentation for linuxTmpfsConst: https://elixir.bootlin.com/linux/v4.6/source/include/uapi/linux/magic.h#L16 -const linuxTmpfsConst int64 = 0x01021994 +// tmpfsMagic is the magic number used to indicate that a unix filesystem is +// a tmpfs. The value is copied from golang.org/x/sys/unix.TMPFS_MAGIC, which we +// can't use here because its source file has a "go:build linux" directive. +// https://cs.opensource.google/go/x/sys/+/refs/tags/v0.34.0:unix/zerrors_linux.go;l=3498 +const tmpfsMagic = 0x1021994 func main() { - flagset := flag.NewFlagSet("sunlight", flag.ExitOnError) + flagset := flag.NewFlagSet("sunlight-secretmanager", flag.ContinueOnError) configFlag := flagset.String("config", "", "Path to YAML config file") + fileSystemFlag := flagset.Int64("filesystem", tmpfsMagic, "OS Filesystem constant to enforce writing to. Defaults to Linux tmpfs") - fileSystemFlag := flagset.Int64("filesystem", linuxTmpfsConst, "OS Filesystem constant to enforce writing to. Defaults to Linux tmpfs") - - if err := flagset.Parse(os.Args[1:]); err != nil { - log.Fatalf("Error parsing flags: %v", err) + err := flagset.Parse(os.Args[1:]) + if err != nil { + log.Fatalf("Error parsing flags: %s", err) } - nameSeedMap, fileNamesMap, err := config.LoadConfigFromYaml(*configFlag) + config, err := loadConfig(*configFlag) if err != nil { - log.Fatalf("failed to read or parse config file: [%v], err: [%v]", configFlag, err) + log.Fatalf("Error loading config: %s", err) } - log.Printf("seeds: %v", nameSeedMap) - ctx := context.Background() cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithSharedConfigProfile(os.Getenv("AWS_PROFILE"))) if err != nil { - log.Fatalf("unable to load AWS config: %v", err) + log.Fatalf("Error loading default AWS config: %s", err) } - secret := secrets.New(cfg) + sm := secretsmanager.NewFromConfig(cfg) - returnedKeys, err := secret.FetchSecrets(ctx, nameSeedMap, fileNamesMap, secrets.Filesystem(*fileSystemFlag)) - if err != nil { - log.Printf("failed to load AWS config: [%v], err: [%v]", configFlag, err) - } + for _, logConfig := range config.Logs { + seed, err := fetchSeed(ctx, sm, logConfig.Secret) + if err != nil { + log.Fatalf("Error fetching seed for %q: %v", logConfig.Name, err) + } - for key := range returnedKeys { - log.Printf("successfully loaded key %v", key) + err = writeFile(logConfig.Secret, seed, *fileSystemFlag) + if err != nil { + log.Fatalf("Error persisting seed for %q: %v", logConfig.Name, err) + } } } diff --git a/cmd/sunlight-secretmanager/seed.go b/cmd/sunlight-secretmanager/seed.go new file mode 100644 index 0000000..021c994 --- /dev/null +++ b/cmd/sunlight-secretmanager/seed.go @@ -0,0 +1,31 @@ +package main + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" +) + +// SecretsManager defines the subset of methods we need from the AWS Secrets Manager +// or equivalent implementation. This makes it easier to mock for testing. +type SecretsManager interface { + GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) +} + +// fetchSeed retrieves a secret value from the provided SecretsManager. +func fetchSeed(ctx context.Context, smClient SecretsManager, id string) ([]byte, error) { + req := &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(id), + VersionStage: aws.String("AWSCURRENT"), + VersionId: nil, + } + + res, err := smClient.GetSecretValue(ctx, req) + if err != nil { + return nil, fmt.Errorf("retrieving secret %q: %w", id, err) + } + + return res.SecretBinary, nil +} diff --git a/cmd/sunlight-secretmanager/seed_test.go b/cmd/sunlight-secretmanager/seed_test.go new file mode 100644 index 0000000..3ad3e03 --- /dev/null +++ b/cmd/sunlight-secretmanager/seed_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" +) + +type fakeSecretsManager struct{} + +var _ SecretsManager = (*fakeSecretsManager)(nil) + +func (sm *fakeSecretsManager) GetSecretValue(_ context.Context, params *secretsmanager.GetSecretValueInput, _ ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { + switch *params.SecretId { + case "missing": + return nil, fmt.Errorf("secret %q not found", *params.SecretId) + case "empty": + return &secretsmanager.GetSecretValueOutput{ //nolint:exhaustruct + Name: aws.String("empty"), + }, nil + case "real": + return &secretsmanager.GetSecretValueOutput{ //nolint:exhaustruct + Name: aws.String("real"), + SecretBinary: []byte("hello world"), + }, nil + default: + return nil, fmt.Errorf("secret %q not recognized", *params.SecretId) + } +} + +func TestFetchSeed(t *testing.T) { + t.Parallel() + + testSM := &fakeSecretsManager{} + + for _, tc := range []struct { + name string + secret string + want []byte + wantErr string + }{ + { + name: "missing secret", + secret: "missing", + want: nil, + wantErr: "retrieving secret", + }, + { + name: "empty", + secret: "empty", + want: []byte{}, + wantErr: "", + }, + { + name: "happy path", + secret: "real", + want: []byte("hello world"), + wantErr: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := fetchSeed(t.Context(), testSM, tc.secret) + if tc.wantErr != "" { //nolint:nestif + if err == nil { + t.Errorf("fetchSeed(%q) = %#v, but want error %q", tc.secret, got, tc.wantErr) + } else if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("fetchSeed(%q) = %#v, but want error %q", tc.secret, err, tc.wantErr) + } + } else { + if err != nil { + t.Errorf("fetchSeed(%q) = %#v, but want no error", tc.secret, err) + } else if !bytes.Equal(got, tc.want) { + t.Errorf("fetchSeed(%q) = %#v, want %#v", tc.secret, got, tc.want) + } + } + }) + } +} diff --git a/cmd/sunlight-secretmanager/testdata/empty.yaml b/cmd/sunlight-secretmanager/testdata/empty.yaml new file mode 100644 index 0000000..e69de29 diff --git a/cmd/sunlight-secretmanager/testdata/happy.yaml b/cmd/sunlight-secretmanager/testdata/happy.yaml new file mode 100644 index 0000000..ae82a3f --- /dev/null +++ b/cmd/sunlight-secretmanager/testdata/happy.yaml @@ -0,0 +1,22 @@ +acme: + email: test@example.com + host: test.sunlight-secretmanager.tld + cache: /path/to/acme/cache/ + +checkpoints: /var/db/sunlight/checkpoints.db + +logs: + + - name: test.tld/shard1 + shortname: shard1 + inception: 2024-08-07 + secret: /path/to/shard1.seed + notafterstart: 2025-01-01T00:00:00Z + notafterlimit: 2025-07-01T00:00:00Z + + - name: test.tld/shard2 + shortname: shard2 + inception: 2024-08-07 + secret: /path/to/shard2.seed + notafterstart: 2025-07-01T00:00:00Z + notafterlimit: 2026-01-01T00:00:00Z diff --git a/cmd/sunlight-secretmanager/testdata/invalid.yaml b/cmd/sunlight-secretmanager/testdata/invalid.yaml new file mode 100644 index 0000000..633688e --- /dev/null +++ b/cmd/sunlight-secretmanager/testdata/invalid.yaml @@ -0,0 +1 @@ +asdfqwer$ diff --git a/cmd/sunlight-secretmanager/testdata/missingkeys.yaml b/cmd/sunlight-secretmanager/testdata/missingkeys.yaml new file mode 100644 index 0000000..aeeafaf --- /dev/null +++ b/cmd/sunlight-secretmanager/testdata/missingkeys.yaml @@ -0,0 +1,12 @@ +logs: + + - name: rome.ct.filippo.io/2025h1 + shortname: 2025h1b + secret: /etc/radiantlog-twig.ct.letsencrypt.org-2025h1b.key + notafterstart: 2025-01-01T00:00:00Z + notafterlimit: 2025-07-01T00:00:00Z + + - name: rome.ct.filippo.io/2025h2 + shortname: 2025h2b + inception: 2024-08-07 + roots: /etc/sunlight/roots.pem diff --git a/cmd/sunlight-secretmanager/testdata/nologs.yaml b/cmd/sunlight-secretmanager/testdata/nologs.yaml new file mode 100644 index 0000000..7bbdf9a --- /dev/null +++ b/cmd/sunlight-secretmanager/testdata/nologs.yaml @@ -0,0 +1,6 @@ +acme: + email: sunlight-acme@filippo.io + host: rome.ct.filippo.io + cache: /var/db/sunlight/autocert/ + +checkpoints: /var/db/sunlight/checkpoints.db diff --git a/config/load_config.go b/config/load_config.go deleted file mode 100644 index 9a7c70f..0000000 --- a/config/load_config.go +++ /dev/null @@ -1,72 +0,0 @@ -// Package config defines sunlight-secretmanager's yaml config format, and -// provides utilities for loading and parsing a config file. -package config - -import ( - "fmt" - "os" - "path/filepath" - - "gopkg.in/yaml.v3" -) - -// Config struct is from Sunlight github: https://github.com/FiloSottile/sunlight/. -// It contains LogConfigs. -type Config struct { - Logs []LogConfig -} - -// LogConfig struct is from Sunlight github: https://github.com/FiloSottile/sunlight/. -// It contains Seeds. -type LogConfig struct { - // Name is the fully qualified log name for the checkpoint origin line, as a - // schema-less URL. It doesn't need to be where the log is actually hosted, - // but that's advisable. - Name string - - // Seed is the path to a file containing a secret seed from which the log's - // private keys are derived. The whole file is used as HKDF input. - // - // To generate a new seed, run: - // - // $ head -c 32 /dev/urandom > seed.bin - // - Seed string -} - -// FileType represents a file with its full path and filename. -type FileType struct { - // Fullpath to secrets file such as /etc/file.key. - Fullpath string - // Name of the secrets file such as file.key. - Filename string -} - -// LoadConfigFromYaml takes path to a yaml file and returns the seeds in that log file. -// Exported for use in main.go. -func LoadConfigFromYaml(configFile string) (map[string]string, map[string]FileType, error) { - yml, err := os.ReadFile(configFile) - if err != nil { - return nil, nil, fmt.Errorf("failed to read config file %v: %w", configFile, err) - } - - var sunlightConfig Config - - if err := yaml.Unmarshal(yml, &sunlightConfig); err != nil { - return nil, nil, fmt.Errorf("failed to parse config file %v: %w", configFile, err) - } - - logs := sunlightConfig.Logs - nameSeedMap := make(map[string]string) - fileNamesMap := make(map[string]FileType) - - for i := range logs { - name := logs[i].Name - seed := logs[i].Seed - filename := filepath.Base(seed) - fileNamesMap[name] = FileType{seed, filename} - nameSeedMap[name] = filepath.Base(seed) - } - - return nameSeedMap, fileNamesMap, nil -} diff --git a/config/load_config_internal_test.go b/config/load_config_internal_test.go deleted file mode 100644 index 8a6a005..0000000 --- a/config/load_config_internal_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package config - -import ( - "reflect" - "testing" -) - -func TestLoadConfigNoFile(t *testing.T) { - t.Parallel() - - testFile := "" - gotSeeds, gotFiles, err := LoadConfigFromYaml(testFile) - - if gotSeeds != nil || gotFiles != nil || err == nil { - t.Errorf("got %q and error %q, wanted error and nil error", gotSeeds, err) - } -} - -func TestLoadConfigCorrect(t *testing.T) { - t.Parallel() - - testFile := "sunlight.yaml" - gotSeeds, gotFiles, err := LoadConfigFromYaml(testFile) - wantFiles := map[string]FileType{ - "rome.ct.filippo.io/2025h1": {"/etc/radiantlog-twig.ct.letsencrypt.org-2025h1b.key", "radiantlog-twig.ct.letsencrypt.org-2025h1b.key"}, - "rome.ct.filippo.io/2025h2": {"radiantlog-twig.ct.letsencrypt.org-2025h2b.key", "radiantlog-twig.ct.letsencrypt.org-2025h2b.key"}, - } - wantSeeds := map[string]string{ - "rome.ct.filippo.io/2025h1": "radiantlog-twig.ct.letsencrypt.org-2025h1b.key", - "rome.ct.filippo.io/2025h2": "radiantlog-twig.ct.letsencrypt.org-2025h2b.key", - } - - if !reflect.DeepEqual(gotSeeds, wantSeeds) || !reflect.DeepEqual(gotFiles, wantFiles) || err != nil { - t.Errorf("got %q and error %q, wanted nil and not nil error", gotSeeds, err) - } -} diff --git a/config/sunlight.yaml b/config/sunlight.yaml deleted file mode 100644 index 13803ef..0000000 --- a/config/sunlight.yaml +++ /dev/null @@ -1,36 +0,0 @@ -acme: - email: sunlight-acme@filippo.io - host: rome.ct.filippo.io - cache: /var/db/sunlight/autocert/ - -checkpoints: /var/db/sunlight/checkpoints.db - -logs: - - - name: rome.ct.filippo.io/2025h1 - shortname: 2025h1b - inception: 2024-08-07 - httpprefix: /2025h1 - roots: /etc/sunlight/roots.pem - seed: /etc/radiantlog-twig.ct.letsencrypt.org-2025h1b.key - cache: /var/db/sunlight/rome2025h1.db - poolsize: 750 - s3region: auto - s3bucket: rome2025h1 - s3endpoint: https://fly.storage.tigris.dev - notafterstart: 2025-01-01T00:00:00Z - notafterlimit: 2025-07-01T00:00:00Z - - - name: rome.ct.filippo.io/2025h2 - shortname: 2025h2b - inception: 2024-08-07 - httpprefix: /2025h2 - roots: /etc/sunlight/roots.pem - seed: radiantlog-twig.ct.letsencrypt.org-2025h2b.key - cache: /var/db/sunlight/rome2025h2.db - poolsize: 750 - s3region: auto - s3bucket: rome2025h2 - s3endpoint: https://fly.storage.tigris.dev - notafterstart: 2025-07-01T00:00:00Z - notafterlimit: 2026-01-01T00:00:00Z \ No newline at end of file diff --git a/go.mod b/go.mod index 8dcf502..f00fa45 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/letsencrypt/sunlight-secretmanager -go 1.23 +go 1.24.0 require gopkg.in/yaml.v3 v3.0.1 @@ -18,5 +18,5 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.24.5 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.4 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.0 // indirect - github.com/aws/smithy-go v1.22.0 + github.com/aws/smithy-go v1.22.0 // indirect ) diff --git a/secrets/fetch_secrets.go b/secrets/fetch_secrets.go deleted file mode 100644 index 02e1ecd..0000000 --- a/secrets/fetch_secrets.go +++ /dev/null @@ -1,116 +0,0 @@ -// Package secrets interacts with the AWS SecretManager API to retrieve secrets -// and store them on a tmpfs. -package secrets - -import ( - "context" - "errors" - "fmt" - "os" - "syscall" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/secretsmanager" - "github.com/letsencrypt/sunlight-secretmanager/config" -) - -// ErrInvalidTmpfs represents error case in which check that file is on a tmpfs fails. -var errInvalidTmpfs = errors.New("invalid tmpfs mount: filesystem check failed") - -// AWSSecretsManagerAPI defines the interface for the AWS Secrets Manager operations required by FetchSecretsHelper. -type AWSSecretsManagerAPI interface { - GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) -} - -// Secrets provides access to secrets stored in AWS. -type Secrets struct { - svc AWSSecretsManagerAPI -} - -// Filesystem represents a filesystem identifier as returned from fstatfs. -type Filesystem int64 - -// New uses the provided config to construct a new AWS Secret Manager client. -func New(cfg aws.Config) *Secrets { - return &Secrets{ - svc: secretsmanager.NewFromConfig(cfg), - } -} - -// FetchSecrets uses Config Profile to initialize AWS SDK configuration. -// Calls FetchSecretsHelper and passes it configured AWS Secrets Manager client. -func (s *Secrets) FetchSecrets(ctx context.Context, seeds map[string]string, fileNamesMap map[string]config.FileType, fsConst Filesystem) (map[string][]byte, error) { - returnedKeys := make(map[string][]byte) - - for _, seedValue := range seeds { - input := &secretsmanager.GetSecretValueInput{ - SecretId: aws.String(seedValue), - VersionStage: aws.String("AWSCURRENT"), - VersionId: nil, - } - - result, err := s.svc.GetSecretValue(ctx, input) - if err != nil { - return nil, fmt.Errorf("failed to retrieve secret for %v: %w", *input.SecretId, err) - } - - res := *result - secretName := res.Name - secretValue := res.SecretBinary - - returnedKeys[*secretName] = secretValue - - _, err = writeToTmpfile(secretValue, fileNamesMap[*secretName], fsConst, verifyFilesystem) - if err != nil { - return nil, fmt.Errorf("couldn't write secret to file %v: %w", fileNamesMap[*secretName].Fullpath, err) - } - } - - return returnedKeys, nil -} - -// WritetoTmpFile opens a file with restrictive user-only-read permissions and writes content to the file if it is on tmpfs. -func writeToTmpfile(secret []byte, fileMeta config.FileType, fsConst Filesystem, verifyFilesystemFunc func(file *os.File, fs Filesystem) error) (string, error) { - // We currently assume that the directory we are trying to write to already exists. - tmpFile, err := os.OpenFile( - fileMeta.Fullpath, - os.O_RDWR|os.O_CREATE|os.O_EXCL, - // Setting nolint here because file permissions octal value isn't a magic number. - //nolint: mnd - 0o400, - ) - if err != nil { - return "", fmt.Errorf("didn't create tmpfile called %v: %w", fileMeta.Fullpath, err) - } - - defer tmpFile.Close() - - err = verifyFilesystemFunc(tmpFile, fsConst) - if err != nil { - os.Remove(tmpFile.Name()) - - return "", err - } - - if _, err := tmpFile.Write(secret); err != nil { - os.Remove(tmpFile.Name()) - - return "", fmt.Errorf("couldn't write to tmpfile: %w", err) - } - - return tmpFile.Name(), nil -} - -// verifyFilesystem verifies that file is on the filesystem defined by fsConst. -func verifyFilesystem(file *os.File, fsConst Filesystem) error { - var statfs syscall.Statfs_t - if err := syscall.Fstatfs(int(file.Fd()), &statfs); err != nil { - return fmt.Errorf("error checking filesystem: %w", err) - } - - if Filesystem(statfs.Type) != fsConst { - return fmt.Errorf("checking if filesystem is %d: %w", fsConst, errInvalidTmpfs) - } - - return nil -} diff --git a/secrets/fetch_secrets_internal_test.go b/secrets/fetch_secrets_internal_test.go deleted file mode 100644 index 8bc9ce1..0000000 --- a/secrets/fetch_secrets_internal_test.go +++ /dev/null @@ -1,242 +0,0 @@ -package secrets - -import ( - "bytes" - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/secretsmanager" - "github.com/aws/smithy-go/middleware" - "github.com/letsencrypt/sunlight-secretmanager/config" -) - -// Represents error cases. -var ( - errSecretIDNil = errors.New("SecretId cannot be nil") - errSecretNotFound = errors.New("secret not found") -) - -// mockSecretsManager API is a mock implementation of AWSSecretsManagerAPI interface. -type mockSecretsManagerAPI func(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) - -// GetSecretValue Implements AWS Secret Manager's GetSecretValue for mock. -func (m mockSecretsManagerAPI) GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { - return m(ctx, params, optFns...) -} - -// TestFetchSecrets defines test cases for the FetchSecrets function using mock implementations of Secrets Manager SDK. -func TestFetchSecrets(t *testing.T) { - t.Parallel() - t.Run("successful secret retrieval", func(t *testing.T) { - t.Parallel() - - runTestFetchSecrets( - t, - map[string]string{"KEY1": "SECRET_1", "KEY2": "SECRET_2"}, - mockSecretsManagerAPI(func(_ context.Context, params *secretsmanager.GetSecretValueInput, _ ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { - if params.SecretId == nil { - return nil, errSecretIDNil - } - - metadata := middleware.Metadata{} - metadata.Set("mock", "true") - - mockSecretOutput := &secretsmanager.GetSecretValueOutput{ - Name: nil, - SecretBinary: nil, - SecretString: nil, - ARN: aws.String("arn:aws:secretsmanager:region:account-id:secret:" + *params.SecretId), - VersionId: aws.String("version-id"), - VersionStages: []string{"AWSCURRENT"}, - CreatedDate: aws.Time(time.Now()), - ResultMetadata: metadata, - } - - switch *params.SecretId { - case "SECRET_1": - mockSecretOutput.Name = aws.String("SECRET_1") - mockSecretOutput.SecretBinary = []byte{226, 151, 186} - - return mockSecretOutput, nil - case "SECRET_2": - mockSecretOutput.Name = aws.String("SECRET_2") - mockSecretOutput.SecretBinary = []byte{104, 101, 108, 108, 111} - - return mockSecretOutput, nil - default: - return nil, errSecretNotFound - } - }), - map[string][]byte{ - "SECRET_1": {226, 151, 186}, - "SECRET_2": {104, 101, 108, 108, 111}, - }, - nil, - ) - }) - t.Run("secret not found", func(t *testing.T) { - t.Parallel() - runTestFetchSecrets( - t, - map[string]string{"KEY1": "SECRET_1"}, - mockSecretsManagerAPI(func(_ context.Context, _ *secretsmanager.GetSecretValueInput, _ ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { - return nil, errSecretNotFound - }), - nil, - errSecretNotFound, - ) - }) -} - -// RunTestFetchSecrets is a helper function to TestFetchSecrets. -// It runs tests to verify that correct secrets are fetched or appropriate errors are returned. -func runTestFetchSecrets( - t *testing.T, - seeds map[string]string, - client AWSSecretsManagerAPI, - expect map[string][]byte, - expectedErr error, -) { - t.Helper() - - var err error - - ctx := context.Background() - - secret := &Secrets{ - svc: client, - } - - returnedKeys := make(map[string][]byte) - - for _, seedValue := range seeds { - input := &secretsmanager.GetSecretValueInput{ - SecretId: aws.String(seedValue), - VersionStage: aws.String("AWSCURRENT"), - VersionId: nil, - } - - result, getErr := secret.svc.GetSecretValue(ctx, input) - if getErr != nil { - err = getErr - - break - } - - returnedKeys[*result.Name] = result.SecretBinary - } - - if !errors.Is(err, expectedErr) { - t.Errorf("expected error %v, got %v", expectedErr, err) - - return - } - - if len(returnedKeys) != len(expect) { - t.Errorf("expected %v keys, got %v keys", len(expect), len(returnedKeys)) - - return - } - - for expectKey, expectVal := range expect { - returnedVal, found := returnedKeys[expectKey] - if !found { - t.Errorf("expected key %s not found in returned keys", expectKey) - - continue - } - - if !bytes.Equal(expectVal, returnedVal) { - t.Errorf("value mismatch for key %s: expected %v, got %v", expectKey, expectVal, returnedVal) - } - } -} - -// TestWriteToTmpfile defines test cases for the writeToTmpfile function using mock implementation of IsFilesystemFunc. -func TestWriteToTmpfile(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - filename config.FileType - secret []byte - mockCheckFunc func(file *os.File, fs Filesystem) error - expectedError error - }{ - { - name: "Successful", - filename: config.FileType{ - Fullpath: "/etc/file.key", - Filename: "file.key", - }, - secret: []byte{226, 151, 186}, - mockCheckFunc: func(_ *os.File, _ Filesystem) error { - return nil - }, - expectedError: nil, - }, - { - name: "Error", - filename: config.FileType{ - Fullpath: "/etc/file.key", - Filename: "file.key", - }, - secret: []byte{226, 151, 186}, - mockCheckFunc: func(_ *os.File, _ Filesystem) error { - return errInvalidTmpfs - }, - expectedError: errInvalidTmpfs, - }, - } - - for _, testcase := range testCases { - t.Run(testcase.name, func(t *testing.T) { - t.Parallel() - runWriteToTmpfileTest(t, testcase) - }) - } -} - -// RunWriteToTmpfileTest is a helper function to TestWriteToTmpfile. -// It runs tests to verify that if a file is correctly mounted on tmpfs, the secrets is correctly written to the file. -func runWriteToTmpfileTest(t *testing.T, testcase struct { - name string - filename config.FileType - secret []byte - mockCheckFunc func(file *os.File, fs Filesystem) error - expectedError error -}, -) { - t.Helper() - - tempDir := t.TempDir() - - testFilename := config.FileType{ - Fullpath: filepath.Join(tempDir, "test.key"), - Filename: "test.key", - } - - result, err := writeToTmpfile(testcase.secret, testFilename, Filesystem(0x01021994), testcase.mockCheckFunc) - - if testcase.expectedError != nil && !errors.Is(err, testcase.expectedError) { - t.Errorf("expected error %v got %v", testcase.expectedError, err) - } - - if testcase.expectedError == nil { - if !strings.HasPrefix(result, tempDir) { - t.Errorf("file not created in expected directory. Got %s, want prefix %s", result, tempDir) - } - - if fileContent, readErr := os.ReadFile(result); readErr != nil { - t.Errorf("failed to read result file: %v", readErr) - } else if !bytes.Equal(fileContent, testcase.secret) { - t.Errorf("file content mismatch") - } - } -}