diff --git a/compose.individual-services.yaml b/compose.individual-services.yaml index f70b25611..eb40444bf 100644 --- a/compose.individual-services.yaml +++ b/compose.individual-services.yaml @@ -47,7 +47,11 @@ services: - devnet restart: "no" secrets: - - database_connection + - source: database_connection + target: database_connection + uid: "102" + gid: "102" + mode: 0440 environment: <<: *env @@ -64,9 +68,17 @@ services: ports: - "10001:10001" # Supervisor secrets: - - auth_mnemonic + - source: auth_mnemonic + target: auth_mnemonic + uid: "102" + gid: "102" + mode: 0400 - blockchain_http_endpoint - - database_connection + - source: database_connection + target: database_connection + uid: "102" + gid: "102" + mode: 0440 environment: <<: *env @@ -87,8 +99,16 @@ services: - "10002:10002" # Supervisor - "10012:10012" # Inspect Service secrets: - - auth_mnemonic - - database_connection + - source: auth_mnemonic + target: auth_mnemonic + uid: "102" + gid: "102" + mode: 0400 + - source: database_connection + target: database_connection + uid: "102" + gid: "102" + mode: 0440 environment: <<: *env @@ -103,7 +123,11 @@ services: ports: - "10003:10003" # Supervisor secrets: - - database_connection + - source: database_connection + target: database_connection + uid: "102" + gid: "102" + mode: 0440 environment: <<: *env @@ -120,9 +144,17 @@ services: ports: - "10004:10004" # Supervisor secrets: - - auth_mnemonic + - source: auth_mnemonic + target: auth_mnemonic + uid: "102" + gid: "102" + mode: 0400 - blockchain_http_endpoint - - database_connection + - source: database_connection + target: database_connection + uid: "102" + gid: "102" + mode: 0440 environment: <<: *env @@ -138,9 +170,17 @@ services: - "10005:10005" # Supervisor - "10011:10011" # Jsonrpc API service secrets: - - auth_mnemonic + - source: auth_mnemonic + target: auth_mnemonic + uid: "102" + gid: "102" + mode: 0400 - blockchain_http_endpoint - - database_connection + - source: database_connection + target: database_connection + uid: "102" + gid: "102" + mode: 0440 environment: <<: *env volumes: diff --git a/compose.yaml b/compose.yaml index 4cedca5f9..0b146945e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -33,6 +33,12 @@ services: interval: 3s timeout: 3s retries: 5 + secrets: + - source: database_connection + target: database_connection + uid: "102" + gid: "102" + mode: 0440 environment: POSTGRES_PASSWORD: password POSTGRES_DB: rollupsdb @@ -46,7 +52,11 @@ services: networks: - devnet secrets: - - database_connection + - source: database_connection + target: database_connection + uid: "102" + gid: "102" + mode: 0440 restart: "no" environment: <<: *env @@ -69,9 +79,17 @@ services: - "10011:10011" # Jsonrpc API service - "10012:10012" # Inspect Service secrets: - - auth_mnemonic + - source: auth_mnemonic + target: auth_mnemonic + uid: "102" + gid: "102" + mode: 0400 - blockchain_http_endpoint - - database_connection + - source: database_connection + target: database_connection + uid: "102" + gid: "102" + mode: 0440 environment: <<: *env diff --git a/docs/secret-files.md b/docs/secret-files.md new file mode 100644 index 000000000..f8bec2b08 --- /dev/null +++ b/docs/secret-files.md @@ -0,0 +1,117 @@ +# Secret File Permissions + +The node reads several sensitive configuration values from files via the +`*_FILE` environment variables. To avoid silently accepting insecure secret +mounts, the node validates the file **type and permissions** before reading +the contents and rejects files that violate the policy. + +This applies to the file-backed variants of: + +| Variable | Policy | Sensitivity | +| ------------------------------------- | ------------- | ---------------------------------------- | +| `CARTESI_AUTH_MNEMONIC_FILE` | strict-secret | signing material (mnemonic) | +| `CARTESI_AUTH_PRIVATE_KEY_FILE` | strict-secret | signing material (private key) | +| `CARTESI_BLOCKCHAIN_HTTP_AUTHORIZATION_FILE` | credential | outbound authorization header | +| `CARTESI_DATABASE_CONNECTION_FILE` | credential | database connection string (DSN) | +| `CARTESI_BLOCKCHAIN_HTTP_ENDPOINT_FILE` | regular-file | endpoint without embedded credentials | + +## Policy tiers + +### strict-secret (signing material) + +Applies to the mnemonic and private-key files. + +The file: + +- **must be a regular file** (not a directory, device, pipe, etc.); +- **must not be readable, writable, or executable by group or world** + (`mode & 0o077` must be `0`, i.e. owner-only, such as `0400` or `0600`); +- **must be owned by the current effective user** on POSIX systems. + +On violation the node refuses to start. This is a hard failure, not a warning. + +### credential (authorization headers, DSNs) + +Applies to files carrying passwords or authorization tokens. + +The file: + +- **must be a regular file**; +- **must not be world-readable or world-writable** + (`mode & 0o007` must be `0`). + +Group-readable files are accepted (e.g. `0640`), so a shared group can mount +credentials when needed. + +### regular-file (non-secret endpoint files) + +Applies to endpoint files that do not carry credentials. + +The file: + +- **must be a regular file**. + +No permission restrictions beyond that. This tier keeps URL convenience files +from being conflated with private key material. + +## Non-POSIX platforms + +On Windows and other platforms that do not expose POSIX mode semantics, only the +regular-file check is enforced; mode and ownership checks are skipped. Treat +this as best-effort hardening, not cross-platform parity. + +## Required file modes + +When mounting secrets into the container, use owner-only permissions for signing +material: + +```sh +chmod 600 /run/secrets/auth_mnemonic +chmod 600 /run/secrets/auth_private_key +``` + +and group-only or owner-only permissions for credential files: + +```sh +chmod 640 /run/secrets/blockchain_http_authorization +chmod 640 /run/secrets/database_connection +``` + +The node runs as a non-root user (`cartesi`, uid 102, gid 102). Secret files must +be readable by that user. + +## Docker Compose + +Docker Compose mounts secrets into `/run/secrets/`. By default they are +**owned by `root`** and **world-readable (`0444`)**, which the `strict-secret` +policy rejects. Mount signing secrets with an explicit mode and ownership so +they are readable only by the node user, for example: + +```yaml +services: + node: + secrets: + - source: auth_mnemonic + target: auth_mnemonic + uid: "102" + gid: "102" + mode: 0400 + +secrets: + auth_mnemonic: + file: test/secrets/auth_mnemonic.txt +``` + +> Note: the `uid`, `gid`, and `mode` fields on service secrets require a recent +> Docker Compose version. Verify support in your environment; if they are +> unsupported, mount the secret as a bind volume with the host file owned by uid +> 102 and mode `0400` instead. + +The node rejects insecure secret files at startup with a message such as: + +``` +failed to parse CARTESI_AUTH_MNEMONIC_FILE: file "/run/secrets/auth_mnemonic" +is accessible by group or others; expected owner-only permissions +``` + +Error messages never include the file contents. diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index c5d298a78..101527406 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -184,6 +184,7 @@ used-by = ["evmreader", "claimer", "node", "prt"] [blockchain.CARTESI_BLOCKCHAIN_HTTP_ENDPOINT] file = true +file-policy = "regular-file" go-type = "URL" description = """ HTTP endpoint for the blockchain RPC provider.""" @@ -191,6 +192,7 @@ used-by = ["evmreader", "claimer", "node", "prt"] [blockchain.CARTESI_BLOCKCHAIN_HTTP_AUTHORIZATION] file = true +file-policy = "credential" go-type = "RedactedString" description = """ Additional security when interacting with providers. @@ -334,6 +336,7 @@ used-by = ["claimer", "node", "cli", "prt"] [auth.CARTESI_AUTH_PRIVATE_KEY] file = true +file-policy = "strict-secret" go-type = "RedactedString" description = """ The node will use this private key to sign transactions.""" @@ -342,6 +345,7 @@ used-by = ["claimer", "node", "cli", "prt"] [auth.CARTESI_AUTH_MNEMONIC] file = true +file-policy = "strict-secret" go-type = "RedactedString" description = """ The node will use the private key generated from this mnemonic to sign transactions.""" @@ -382,6 +386,7 @@ used-by = ["claimer", "node", "cli", "prt"] [database.CARTESI_DATABASE_CONNECTION] default = "" file = true +file-policy = "credential" go-type = "URL" description = """ Postgres endpoint in the 'postgres://user:password@hostname:port/database' format (URL). diff --git a/internal/config/generate/code.go b/internal/config/generate/code.go index f8f8ed97c..af1eefa58 100644 --- a/internal/config/generate/code.go +++ b/internal/config/generate/code.go @@ -52,6 +52,20 @@ var funcMap = template.FuncMap{ // For example, "int" becomes "toInt", "Duration" becomes "toDuration". return "to" + strings.ToUpper(goType[:1]) + goType[1:] }, + // toFilePolicyConst maps a Config.toml file-policy value to the + // corresponding SecretFilePolicy constant name emitted in generated code. + "toFilePolicyConst": func(policy string) string { + switch policy { + case filePolicyStrict: + return "SecretFilePolicyStrict" + case filePolicyCredential: + return "SecretFilePolicyCredential" + case "", filePolicyRegular: + return "SecretFilePolicyRegularFileOnly" + default: + panic("invalid file-policy: " + policy) + } + }, // splitLines splits a string into lines (by "\n"). "splitLines": func(s string) []string { return strings.Split(s, "\n") @@ -122,7 +136,6 @@ package config import ( "fmt" - "os" "strings" "github.com/spf13/viper" @@ -221,7 +234,7 @@ func Get{{ toFieldName .Name }}() ({{ .GoType }}, error) { {{- if .File }} if s == "" { filename := viper.GetString({{toConstName .Name}}_FILE) - contents, err := os.ReadFile(filename) + contents, err := ReadConfigFileWithPolicy(filename, {{ toFilePolicyConst .FilePolicy }}) if err != nil { return notDefined{{ .GoType }}(), fmt.Errorf("failed to parse %s: %w", {{ toConstName .Name }}_FILE, err) } diff --git a/internal/config/generate/docs.go b/internal/config/generate/docs.go index 78b44d795..b8b217e42 100644 --- a/internal/config/generate/docs.go +++ b/internal/config/generate/docs.go @@ -25,6 +25,18 @@ func generateDocsFile(path string, env []Env) { "quote": func(s string) string { return `"` + s + `"` }, + "filePolicyDesc": func(p string) string { + switch p { + case filePolicyStrict: + return "strict-secret (owner-only regular file; rejects group/world access)" + case filePolicyCredential: + return "credential (regular file; rejects world access)" + case "", filePolicyRegular: + return "regular-file (must be a regular file)" + default: + return p + } + }, } tmpl := template.Must(template.New("docs").Funcs(funcMap).Parse(docsTemplate)) @@ -56,6 +68,9 @@ This file documents the configuration options. {{.Description}} * **Type:** {{backtick .GoType}} +{{- if .File}} +* **File policy:** {{filePolicyDesc .FilePolicy}} +{{- end}} {{- if .Default}} * **Default:** {{.Default | quote | backtick}} {{- end}} diff --git a/internal/config/generate/env.go b/internal/config/generate/env.go index e13eb1634..316a5d3a2 100644 --- a/internal/config/generate/env.go +++ b/internal/config/generate/env.go @@ -3,6 +3,15 @@ package main +// File-policy values understood by the generator. They map to SecretFilePolicy +// constants in the config package via toFilePolicyConst, and to human-readable +// descriptions via filePolicyDesc. +const ( + filePolicyStrict = "strict-secret" + filePolicyCredential = "credential" + filePolicyRegular = "regular-file" +) + // An entry in the toml's top level table representing an environment variable. type Env struct { // Name of the environment variable. @@ -26,6 +35,12 @@ type Env struct { // Configuration also has a "_FILE" variant that should be searched File bool `toml:"file"` + // FilePolicy classifies the sensitivity of the file-backed variant so the + // generated getter validates permissions before reading. Only meaningful + // when File is true. Valid values: "strict-secret", "credential", + // "regular-file". Defaults to "regular-file" when empty. + FilePolicy string `toml:"file-policy"` + // List of services that use this environment variable. // Possible values: "advancer", "claimer", "cli", "evm-reader", "jsonrpc-api", "node", "validator" UsedBy []string `toml:"used-by"` @@ -40,4 +55,12 @@ func (e *Env) validate() { if e.Description == "" { panic("missing description for " + e.Name) } + if e.File { + switch e.FilePolicy { + case "", filePolicyStrict, filePolicyCredential, filePolicyRegular: + // valid; empty defaults to filePolicyRegular at render time. + default: + panic("invalid file-policy " + e.FilePolicy + " for " + e.Name) + } + } } diff --git a/internal/config/generated.go b/internal/config/generated.go index cab15bdc8..9fada8cb7 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -8,7 +8,6 @@ package config import ( "fmt" - "os" "strings" "github.com/spf13/viper" @@ -1730,7 +1729,7 @@ func GetAuthMnemonic() (RedactedString, error) { s := viper.GetString(AUTH_MNEMONIC) if s == "" { filename := viper.GetString(AUTH_MNEMONIC_FILE) - contents, err := os.ReadFile(filename) + contents, err := ReadConfigFileWithPolicy(filename, SecretFilePolicyStrict) if err != nil { return notDefinedRedactedString(), fmt.Errorf("failed to parse %s: %w", AUTH_MNEMONIC_FILE, err) } @@ -1764,7 +1763,7 @@ func GetAuthPrivateKey() (RedactedString, error) { s := viper.GetString(AUTH_PRIVATE_KEY) if s == "" { filename := viper.GetString(AUTH_PRIVATE_KEY_FILE) - contents, err := os.ReadFile(filename) + contents, err := ReadConfigFileWithPolicy(filename, SecretFilePolicyStrict) if err != nil { return notDefinedRedactedString(), fmt.Errorf("failed to parse %s: %w", AUTH_PRIVATE_KEY_FILE, err) } @@ -1798,7 +1797,7 @@ func GetBlockchainHttpAuthorization() (RedactedString, error) { s := viper.GetString(BLOCKCHAIN_HTTP_AUTHORIZATION) if s == "" { filename := viper.GetString(BLOCKCHAIN_HTTP_AUTHORIZATION_FILE) - contents, err := os.ReadFile(filename) + contents, err := ReadConfigFileWithPolicy(filename, SecretFilePolicyCredential) if err != nil { return notDefinedRedactedString(), fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_HTTP_AUTHORIZATION_FILE, err) } @@ -1819,7 +1818,7 @@ func GetBlockchainHttpEndpoint() (URL, error) { s := viper.GetString(BLOCKCHAIN_HTTP_ENDPOINT) if s == "" { filename := viper.GetString(BLOCKCHAIN_HTTP_ENDPOINT_FILE) - contents, err := os.ReadFile(filename) + contents, err := ReadConfigFileWithPolicy(filename, SecretFilePolicyRegularFileOnly) if err != nil { return notDefinedURL(), fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_HTTP_ENDPOINT_FILE, err) } @@ -1944,7 +1943,7 @@ func GetDatabaseConnection() (URL, error) { s := viper.GetString(DATABASE_CONNECTION) if s == "" { filename := viper.GetString(DATABASE_CONNECTION_FILE) - contents, err := os.ReadFile(filename) + contents, err := ReadConfigFileWithPolicy(filename, SecretFilePolicyCredential) if err != nil { return notDefinedURL(), fmt.Errorf("failed to parse %s: %w", DATABASE_CONNECTION_FILE, err) } diff --git a/internal/config/secret_files.go b/internal/config/secret_files.go new file mode 100644 index 000000000..45ed33886 --- /dev/null +++ b/internal/config/secret_files.go @@ -0,0 +1,51 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "fmt" + "os" +) + +// SecretFilePolicy describes how strictly a file-backed config value is +// validated before its contents are read. +type SecretFilePolicy int + +const ( + // SecretFilePolicyStrict is for signing material such as mnemonic and + // private-key files. The file must be a regular file, owned by the + // current effective user, and accessible only by its owner. + SecretFilePolicyStrict SecretFilePolicy = iota + + // SecretFilePolicyCredential is for files carrying credentials such as + // authorization headers and database connection strings. The file must + // be a regular file and must not be world-accessible. + SecretFilePolicyCredential + + // SecretFilePolicyRegularFileOnly is for non-secret files such as + // endpoint files without embedded credentials. Only the regular-file + // check is enforced. + SecretFilePolicyRegularFileOnly +) + +// ReadConfigFileWithPolicy validates path according to policy and, on success, +// returns its contents verbatim. The path is used in error messages; the file +// contents are never included in errors. +// +// The regular-file check is enforced on every platform. Permission and +// ownership checks are enforced only where the platform exposes POSIX mode +// semantics (see checkSecretFilePermissions). +func ReadConfigFileWithPolicy(path string, policy SecretFilePolicy) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("file %q is not a regular file", path) + } + if err := checkSecretFilePermissions(path, info, policy); err != nil { + return nil, err + } + return os.ReadFile(path) +} diff --git a/internal/config/secret_files_getter_test.go b/internal/config/secret_files_getter_test.go new file mode 100644 index 000000000..6b085351f --- /dev/null +++ b/internal/config/secret_files_getter_test.go @@ -0,0 +1,79 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +// TestGetAuthMnemonicRejectsInsecureFile verifies that the generated getter for +// the mnemonic file enforces the strict secret-file policy end to end: a +// world-readable mnemonic file is rejected before its contents are parsed. +func TestGetAuthMnemonicRejectsInsecureFile(t *testing.T) { + requirePOSIXMode(t) + + path := writeFile(t, 0o644, "test test test test test test test test test test test junk") + + viper.Reset() + defer viper.Reset() + viper.Set(AUTH_MNEMONIC_FILE, path) + + _, err := GetAuthMnemonic() + require.Error(t, err) + require.Contains(t, err.Error(), AUTH_MNEMONIC_FILE) + require.Contains(t, err.Error(), "owner-only permissions") +} + +// TestGetAuthMnemonicAcceptsSecureFile verifies the happy path: an owner-only +// mnemonic file is read and its contents are returned. +func TestGetAuthMnemonicAcceptsSecureFile(t *testing.T) { + requirePOSIXMode(t) + + path := writeFile(t, 0o600, "test test test test test test test test test test test junk") + + viper.Reset() + defer viper.Reset() + viper.Set(AUTH_MNEMONIC_FILE, path) + + mnemonic, err := GetAuthMnemonic() + require.NoError(t, err) + require.Equal(t, + "test test test test test test test test test test test junk", + mnemonic.Value, + ) +} + +// TestGetBlockchainHttpEndpointAcceptsRegularFile verifies that a non-secret, +// endpoint-only file passes the regular-file policy even when world-readable. +func TestGetBlockchainHttpEndpointAcceptsRegularFile(t *testing.T) { + path := writeFile(t, 0o644, "http://ethereum_provider:8545") + + viper.Reset() + defer viper.Reset() + viper.Set(BLOCKCHAIN_HTTP_ENDPOINT_FILE, path) + + endpoint, err := GetBlockchainHttpEndpoint() + require.NoError(t, err) + require.Equal(t, "http://ethereum_provider:8545", endpoint.String()) +} + +// TestGetDatabaseConnectionRejectsWorldReadableFile verifies that the +// credential-tier policy rejects a world-readable DSN file. +func TestGetDatabaseConnectionRejectsWorldReadableFile(t *testing.T) { + requirePOSIXMode(t) + + path := writeFile(t, 0o644, "postgres://user:password@database:5432/rollupsdb?sslmode=disable") + + viper.Reset() + defer viper.Reset() + viper.Set(DATABASE_CONNECTION_FILE, path) + + _, err := GetDatabaseConnection() + require.Error(t, err) + require.Contains(t, err.Error(), DATABASE_CONNECTION_FILE) + require.Contains(t, err.Error(), "world-accessible") +} diff --git a/internal/config/secret_files_test.go b/internal/config/secret_files_test.go new file mode 100644 index 000000000..1610582c1 --- /dev/null +++ b/internal/config/secret_files_test.go @@ -0,0 +1,102 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +// writeFile writes contents to a temp file with the given mode and returns its +// path. +func writeFile(t *testing.T, mode os.FileMode, contents string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "secret") + require.NoError(t, os.WriteFile(path, []byte(contents), mode)) + // WriteFile may apply the umask; force the exact requested mode. + require.NoError(t, os.Chmod(path, mode)) + return path +} + +// requirePOSIXMode skips the test on platforms that do not expose POSIX mode +// semantics. Permission and ownership checks are no-ops there, so tests that +// assert rejection cannot pass. +func requirePOSIXMode(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("POSIX mode checks are not available on Windows") + } +} + +func TestReadConfigFileWithPolicyRegularFile(t *testing.T) { + path := writeFile(t, 0o600, "hello\n") + result, err := ReadConfigFileWithPolicy(path, SecretFilePolicyStrict) + require.NoError(t, err) + // Contents are returned verbatim, not trimmed. + expected := []byte("hello\n") + require.Equal(t, expected, result) +} + +func TestReadConfigFileWithPolicyMissingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "does-not-exist") + _, err := ReadConfigFileWithPolicy(path, SecretFilePolicyStrict) + require.Error(t, err) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestReadConfigFileWithPolicyNonRegularFile(t *testing.T) { + dir := t.TempDir() + // A directory is not a regular file. + _, err := ReadConfigFileWithPolicy(dir, SecretFilePolicyRegularFileOnly) + require.Error(t, err) + require.Contains(t, err.Error(), "is not a regular file") +} + +func TestReadConfigFileWithPolicyStrictRejectsWorldReadable(t *testing.T) { + requirePOSIXMode(t) + path := writeFile(t, 0o644, "hello\n") + _, err := ReadConfigFileWithPolicy(path, SecretFilePolicyStrict) + require.Error(t, err) + require.Contains(t, err.Error(), "owner-only permissions") +} + +func TestReadConfigFileWithPolicyStrictRejectsGroupReadable(t *testing.T) { + requirePOSIXMode(t) + path := writeFile(t, 0o640, "hello\n") + _, err := ReadConfigFileWithPolicy(path, SecretFilePolicyStrict) + require.Error(t, err) + require.Contains(t, err.Error(), "owner-only permissions") +} + +func TestReadConfigFileWithPolicyCredentialRejectsWorldReadable(t *testing.T) { + requirePOSIXMode(t) + path := writeFile(t, 0o644, "hello\n") + _, err := ReadConfigFileWithPolicy(path, SecretFilePolicyCredential) + require.Error(t, err) + require.Contains(t, err.Error(), "world-accessible") +} + +func TestReadConfigFileWithPolicyCredentialAcceptsGroupReadable(t *testing.T) { + requirePOSIXMode(t) + // Group-readable but not world-accessible: credential policy permits it, + // while strict policy would reject it. + path := writeFile(t, 0o640, "hello\n") + result, err := ReadConfigFileWithPolicy(path, SecretFilePolicyCredential) + require.NoError(t, err) + expected := []byte("hello\n") + require.Equal(t, expected, result) +} + +func TestReadConfigFileWithPolicyRegularFileOnlyAcceptsWorldReadable(t *testing.T) { + path := writeFile(t, 0o644, "hello\n") + result, err := ReadConfigFileWithPolicy(path, SecretFilePolicyRegularFileOnly) + require.NoError(t, err) + expected := []byte("hello\n") + require.Equal(t, expected, result) +} diff --git a/internal/config/secret_files_unix.go b/internal/config/secret_files_unix.go new file mode 100644 index 000000000..e848986c7 --- /dev/null +++ b/internal/config/secret_files_unix.go @@ -0,0 +1,54 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build !windows + +package config + +import ( + "fmt" + "os" + "syscall" +) + +// checkSecretFilePermissions enforces POSIX mode and ownership rules for the +// given policy. It is a no-op for SecretFilePolicyRegularFileOnly. +func checkSecretFilePermissions(path string, info os.FileInfo, policy SecretFilePolicy) error { + perm := info.Mode().Perm() + switch policy { + case SecretFilePolicyStrict: + if perm&0o077 != 0 { + return fmt.Errorf( + "file %q is accessible by group or others; expected owner-only permissions", + path, + ) + } + case SecretFilePolicyCredential: + if perm&0o007 != 0 { + return fmt.Errorf( + "file %q is world-accessible; expected owner or group-only permissions", + path, + ) + } + case SecretFilePolicyRegularFileOnly: + // No permission restrictions beyond the regular-file check. + } + + if policy != SecretFilePolicyStrict { + return nil + } + + // Ownership is enforced only for signing secrets. If ownership cannot be + // determined, do not fail solely for that reason. + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return nil + } + if euid := syscall.Geteuid(); int(stat.Uid) != euid { + return fmt.Errorf( + "file %q is owned by uid %d but the current effective user is %d", + path, stat.Uid, euid, + ) + } + return nil +} diff --git a/internal/config/secret_files_windows.go b/internal/config/secret_files_windows.go new file mode 100644 index 000000000..27120dc1f --- /dev/null +++ b/internal/config/secret_files_windows.go @@ -0,0 +1,18 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build windows + +package config + +import "os" + +// checkSecretFilePermissions is a no-op on Windows: the platform does not +// expose POSIX mode or ownership semantics, so only the regular-file check +// (enforced in ReadConfigFileWithPolicy) applies. +func checkSecretFilePermissions(path string, info os.FileInfo, policy SecretFilePolicy) error { + _ = path + _ = info + _ = policy + return nil +}