diff --git a/compose.individual-services.yaml b/compose.individual-services.yaml index f70b25611..72af14f02 100644 --- a/compose.individual-services.yaml +++ b/compose.individual-services.yaml @@ -37,17 +37,39 @@ services: POSTGRES_PASSWORD: password POSTGRES_DB: rollupsdb + # NOTE: compose can't override uid/gid/mode for secrets, it bind mounts them + # with the host credentials. The portable workaround to deal with this was to + # use volumes instead and manually chown/chmod the files upon creation. + secret-init: + image: cartesi/rollups-node:devel + user: root # required by: chown + entrypoint: + - sh + - -c + - | + set -e + cp /src/* /dst/ + chown 102:102 /dst /dst/* + chmod 0400 /dst/* + volumes: + - ./test/secrets:/src:ro + - node_secrets:/dst + network_mode: "none" + restart: "no" + migration: image: cartesi/rollups-node:devel command: cartesi-rollups-cli db init depends_on: + secret-init: + condition: service_completed_successfully database: condition: service_healthy + volumes: + - node_secrets:/run/secrets:ro networks: - devnet restart: "no" - secrets: - - database_connection environment: <<: *env @@ -55,18 +77,18 @@ services: image: cartesi/rollups-node:devel command: cartesi-rollups-evm-reader depends_on: + secret-init: + condition: service_completed_successfully database: condition: service_healthy ethereum_provider: condition: service_healthy + volumes: + - node_secrets:/run/secrets:ro networks: - devnet ports: - "10001:10001" # Supervisor - secrets: - - auth_mnemonic - - blockchain_http_endpoint - - database_connection environment: <<: *env @@ -75,20 +97,20 @@ services: init: true command: cartesi-rollups-advancer depends_on: + secret-init: + condition: service_completed_successfully database: condition: service_healthy ethereum_provider: condition: service_healthy volumes: - node_data:/var/lib/cartesi-rollups-node/data + - node_secrets:/run/secrets:ro networks: - devnet ports: - "10002:10002" # Supervisor - "10012:10012" # Inspect Service - secrets: - - auth_mnemonic - - database_connection environment: <<: *env @@ -96,14 +118,16 @@ services: image: cartesi/rollups-node:devel command: cartesi-rollups-validator depends_on: + secret-init: + condition: service_completed_successfully database: condition: service_healthy + volumes: + - node_secrets:/run/secrets:ro networks: - devnet ports: - "10003:10003" # Supervisor - secrets: - - database_connection environment: <<: *env @@ -111,18 +135,18 @@ services: image: cartesi/rollups-node:devel command: cartesi-rollups-claimer depends_on: + secret-init: + condition: service_completed_successfully database: condition: service_healthy ethereum_provider: condition: service_healthy + volumes: + - node_secrets:/run/secrets:ro networks: - devnet ports: - "10004:10004" # Supervisor - secrets: - - auth_mnemonic - - blockchain_http_endpoint - - database_connection environment: <<: *env @@ -130,29 +154,22 @@ services: image: cartesi/rollups-node:devel command: cartesi-rollups-jsonrpc-api depends_on: + secret-init: + condition: service_completed_successfully database: condition: service_healthy + volumes: + - node_secrets:/run/secrets:ro networks: - devnet ports: - "10005:10005" # Supervisor - "10011:10011" # Jsonrpc API service - secrets: - - auth_mnemonic - - blockchain_http_endpoint - - database_connection environment: <<: *env volumes: node_data: + node_secrets: networks: devnet: - -secrets: - auth_mnemonic: - file: test/secrets/auth_mnemonic.txt - blockchain_http_endpoint: - file: test/secrets/blockchain_http_endpoint.txt - database_connection: - file: test/secrets/database_connection.txt diff --git a/compose.yaml b/compose.yaml index 4cedca5f9..a0f338ca1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -37,16 +37,38 @@ services: POSTGRES_PASSWORD: password POSTGRES_DB: rollupsdb + # NOTE: compose can't override uid/gid/mode for secrets, it bind mounts them + # with the host credentials. The portable workaround to deal with this was to + # use volumes instead and manually chown/chmod the files upon creation. + secret-init: + image: cartesi/rollups-node:devel + user: root # required by: chown + entrypoint: + - sh + - -c + - | + set -e + cp /src/* /dst/ + chown 102:102 /dst /dst/* + chmod 0400 /dst/* + volumes: + - ./test/secrets:/src:ro + - node_secrets:/dst + network_mode: "none" + restart: "no" + migration: image: cartesi/rollups-node:devel command: cartesi-rollups-cli db init depends_on: + secret-init: + condition: service_completed_successfully database: condition: service_healthy networks: - devnet - secrets: - - database_connection + volumes: + - node_secrets:/run/secrets:ro restart: "no" environment: <<: *env @@ -56,35 +78,27 @@ services: init: true command: cartesi-rollups-node depends_on: + secret-init: + condition: service_completed_successfully database: condition: service_healthy ethereum_provider: condition: service_healthy volumes: - node_data:/var/lib/cartesi-rollups-node/data + - node_secrets:/run/secrets:ro networks: - devnet ports: - "10000:10000" # Supervisor - "10011:10011" # Jsonrpc API service - "10012:10012" # Inspect Service - secrets: - - auth_mnemonic - - blockchain_http_endpoint - - database_connection environment: <<: *env volumes: node_data: + node_secrets: networks: devnet: - -secrets: - auth_mnemonic: - file: test/secrets/auth_mnemonic.txt - blockchain_http_endpoint: - file: test/secrets/blockchain_http_endpoint.txt - database_connection: - file: test/secrets/database_connection.txt diff --git a/docs/secrets.md b/docs/secrets.md new file mode 100644 index 000000000..7b95cbbd4 --- /dev/null +++ b/docs/secrets.md @@ -0,0 +1,106 @@ +# Secret File Handling + +**Applies to:** deployments that pass credentials to the node via `*_FILE` +variables (database credentials, RPC endpoints, signing keys, API tokens), +on Kubernetes or Docker Compose. + +## Node expectations + +Every value loaded from a `*_FILE` variable is validated at startup, before +use; the node refuses to start on violation. The path must resolve to a +**regular file** — symlinks, directories, FIFOs, and device nodes are +rejected. + +Secrets must match one of two canonical forms tailored to common deployment scenarios: + +| Deployment | Ownership | Mode | +| ----------------- | --------------------------------- | -----------------| +| Kubernetes Secret | `root:fsGroup` (the node's group) | `0440` | +| Compose / host | the node's user | `0400` or `0600` | + +Anything else is rejected: world bits, `0440` with a non-root owner, `0440` +whose group is not the node's, and `0400`/`0600` owned by another user. The +owner write bit (`0600`) is accepted in the Compose form so the owner can +rotate the file in place. + +Affected variables: + +- `CARTESI_AUTH_MNEMONIC_FILE`, +- `CARTESI_AUTH_PRIVATE_KEY_FILE`, +- `CARTESI_BLOCKCHAIN_HTTP_AUTHORIZATION_FILE`, +- `CARTESI_DATABASE_CONNECTION_FILE`, +- `CARTESI_BLOCKCHAIN_HTTP_ENDPOINT_FILE`. + +Errors name the file but never its contents: + +```text +failed to parse CARTESI_AUTH_MNEMONIC_FILE: secret file "/run/secrets/auth_mnemonic" does not conform with uid/gid/mode rules +``` + +On non-POSIX platforms only the regular-file check is enforced. + +## Kubernetes + +Mount the `Secret` as a read-only volume (not env vars), with +`defaultMode: 0440` and a Pod `fsGroup` equal to the node's GID: the +kubelet creates the files root-owned with that group, which is exactly the +canonical form above. `fsGroup` must equal the node user's primary GID +(102 in the stock image) and `runAsUser` must be the node's UID (102). +The check compares the file's group against the process's primary GID, +not its supplementary groups, so a different `fsGroup` (e.g. 65534) is +rejected even though Kubernetes makes the file readable. + +## Docker Compose + +Compose ignores `uid`/`gid`/`mode` on file-based secrets (it warns and +discards them): the in-container ownership and mode are exactly the **host +file's** numeric UID/GID and mode. The node's startup validation is the only +enforcement point, so the file as seen from the container must already match +the Compose canonical form. + +The straightforward workaround is to fix the host file itself — `chown +102:102` and `chmod 0400` (or `0600`). + +An option that never modifies host files is a one-shot `secret-init` +service: run as root (only to write the fresh root-owned volume), it copies +the files into a named volume with canonical `102:102` / `0400` identity, +and the node mounts that volume read-only at `/run/secrets`. Host file +ownership and modes do not matter — the copy is normalized; the copy +re-runs on every `up`; `down -v` wipes the volume. `compose.yaml` +implements this pattern: + +```yaml +services: + secret-init: + image: cartesi/rollups-node:devel + user: root # required by: chown + entrypoint: + - sh + - -c + - | + set -e + cp /src/* /dst/ + chown 102:102 /dst /dst/* + chmod 0400 /dst/* + volumes: + - ./test/secrets:/src:ro + - node_secrets:/dst + network_mode: "none" + restart: "no" + + node: + depends_on: + secret-init: + condition: service_completed_successfully + volumes: + - node_secrets:/run/secrets:ro + +volumes: + node_secrets: +``` + +## Checklist + +- [ ] `*_FILE` secret files match a canonical form: `0400`/`0600` node-owned (Compose) or `0440` root:fsGroup (Kubernetes) +- [ ] Kubernetes: `defaultMode: 0440`, `fsGroup` = node user's primary GID (102), `runAsUser` = node's UID (102) +- [ ] Compose: host files `chown 102:102` / `chmod 0400` (YAML `uid`/`gid`/`mode` are ignored) diff --git a/internal/config/generate/code.go b/internal/config/generate/code.go index f8f8ed97c..d8635ae17 100644 --- a/internal/config/generate/code.go +++ b/internal/config/generate/code.go @@ -122,7 +122,6 @@ package config import ( "fmt" - "os" "strings" "github.com/spf13/viper" @@ -221,7 +220,7 @@ func Get{{ toFieldName .Name }}() ({{ .GoType }}, error) { {{- if .File }} if s == "" { filename := viper.GetString({{toConstName .Name}}_FILE) - contents, err := os.ReadFile(filename) + contents, err := ReadSecretFile(filename) 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..99df78019 100644 --- a/internal/config/generate/docs.go +++ b/internal/config/generate/docs.go @@ -59,6 +59,14 @@ This file documents the configuration options. {{- if .Default}} * **Default:** {{.Default | quote | backtick}} {{- end}} +{{- if .File}} +* **File variant:** {{printf "%s_FILE" .Name | backtick}} reads the value from a file. + The file must be a regular file matching one of the canonical forms: + {{backtick "0400"}} or {{backtick "0600"}} owned by the node's user (Compose / host files), or + {{backtick "0440"}} owned by root with the node's group (Kubernetes {{backtick "fsGroup"}}). + Symlinks, directories, FIFOs, and device nodes are rejected. The node + refuses to start otherwise. +{{- end}} {{- if .UsedBy}} * **Used by:** {{range $i, $e := .UsedBy}}{{if $i}}, {{end}}{{$e}}{{end}} {{- end}} diff --git a/internal/config/generated.go b/internal/config/generated.go index cab15bdc8..4bb1f2315 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 := ReadSecretFile(filename) 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 := ReadSecretFile(filename) 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 := ReadSecretFile(filename) 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 := ReadSecretFile(filename) 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 := ReadSecretFile(filename) if err != nil { return notDefinedURL(), fmt.Errorf("failed to parse %s: %w", DATABASE_CONNECTION_FILE, err) } diff --git a/internal/config/secret_file.go b/internal/config/secret_file.go new file mode 100644 index 000000000..3b5cdd46f --- /dev/null +++ b/internal/config/secret_file.go @@ -0,0 +1,47 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "errors" + "fmt" + "io" + "os" + "syscall" +) + +// ReadSecretFile reads the file at path and returns its contents verbatim. +// +// The path is opened once and every check (regular file, permissions) is +// performed on the opened descriptor, so validation always applies to the +// object that is actually read. On POSIX the open is non-blocking and does +// not follow symlinks (see secretFileOpenFlags), so a FIFO, blocking +// device node, or symlink at the path is rejected instead of stalling +// startup. The permission policy is enforced on POSIX systems (see +// checkSecretFilePermissions); on other platforms only the regular-file +// check applies. +// +// Errors report the path, never the file contents. +func ReadSecretFile(path string) ([]byte, error) { + f, err := os.OpenFile(path, secretFileOpenFlags, 0) + if err != nil { + if errors.Is(err, syscall.ELOOP) { + return nil, fmt.Errorf("secret file %q is a symlink", path) + } + return nil, err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("secret file %q is not a regular file", path) + } + if err := checkSecretFilePermissions(path, info); err != nil { + return nil, err + } + return io.ReadAll(f) +} diff --git a/internal/config/secret_file_other.go b/internal/config/secret_file_other.go new file mode 100644 index 000000000..67d047dde --- /dev/null +++ b/internal/config/secret_file_other.go @@ -0,0 +1,20 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build !unix + +package config + +import ( + "io/fs" + "os" +) + +// secretFileOpenFlags: O_NONBLOCK does not exist on non-POSIX platforms. +const secretFileOpenFlags = os.O_RDONLY + +// checkSecretFilePermissions is a no-op on platforms without POSIX mode +// semantics; the regular-file check in ReadSecretFile still applies. +func checkSecretFilePermissions(_ string, _ fs.FileInfo) error { + return nil +} diff --git a/internal/config/secret_file_test.go b/internal/config/secret_file_test.go new file mode 100644 index 000000000..57c20b6fb --- /dev/null +++ b/internal/config/secret_file_test.go @@ -0,0 +1,172 @@ +// (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/spf13/viper" + "github.com/stretchr/testify/require" +) + +func writeSecretFile(t *testing.T, mode os.FileMode, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "secret") + require.NoError(t, os.WriteFile(path, []byte(contents), mode)) + return path +} + +// skipIfNoPermissionPolicy skips tests that depend on the permission policy, +// which the test suite only exercises on linux and darwin. +func skipIfNoPermissionPolicy(t *testing.T) { + t.Helper() + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skipf("permission policy tests require linux or darwin, running on %s", runtime.GOOS) + } +} + +func TestReadSecretFileRegularFile(t *testing.T) { + const contents = "top secret value\n" + + t.Run("returns contents verbatim", func(t *testing.T) { + path := writeSecretFile(t, 0o400, contents) + data, err := ReadSecretFile(path) + require.NoError(t, err) + require.Equal(t, contents, string(data)) + }) + + t.Run("rejects missing file", func(t *testing.T) { + _, err := ReadSecretFile(filepath.Join(t.TempDir(), "missing")) + require.Error(t, err) + }) + + t.Run("rejects directory", func(t *testing.T) { + _, err := ReadSecretFile(t.TempDir()) + require.ErrorContains(t, err, "not a regular file") + }) + + t.Run("rejects symlink even to a canonical target", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating symlinks requires elevated privileges on windows") + } + target := writeSecretFile(t, 0o400, "linked secret") + link := filepath.Join(t.TempDir(), "link") + require.NoError(t, os.Symlink(target, link)) + _, err := ReadSecretFile(link) + require.ErrorContains(t, err, "is a symlink") + }) +} + +func TestReadSecretFilePermissions(t *testing.T) { + skipIfNoPermissionPolicy(t) + const contents = "top secret value\n" + + t.Run("accepts 0400 owned by the process user (compose form)", func(t *testing.T) { + path := writeSecretFile(t, 0o400, contents) + data, err := ReadSecretFile(path) + require.NoError(t, err) + require.Equal(t, contents, string(data)) + }) + + t.Run("accepts 0440 root:process-group (kubernetes form)", func(t *testing.T) { + path := writeSecretFile(t, 0o644, contents) + if err := os.Chown(path, 0, os.Getegid()); err != nil { + t.Skipf("cannot chown to root: %v", err) + } + require.NoError(t, os.Chmod(path, 0o440)) + _, err := ReadSecretFile(path) + require.NoError(t, err) + }) + + t.Run("accepts 0600 owned by the process user (compose form, owner-writable)", func(t *testing.T) { + path := writeSecretFile(t, 0o600, contents) + data, err := ReadSecretFile(path) + require.NoError(t, err) + require.Equal(t, contents, string(data)) + }) + + t.Run("rejects 0411 (owner exec bit is not a canonical form)", func(t *testing.T) { + path := writeSecretFile(t, 0o411, contents) + _, err := ReadSecretFile(path) + require.ErrorContains(t, err, "does not conform with uid/gid/mode rules") + }) + + t.Run("rejects world-readable 0644", func(t *testing.T) { + path := writeSecretFile(t, 0o644, contents) + _, err := ReadSecretFile(path) + require.ErrorContains(t, err, "does not conform with uid/gid/mode rules") + }) + + t.Run("rejects world-accessible 0755", func(t *testing.T) { + path := writeSecretFile(t, 0o755, contents) + _, err := ReadSecretFile(path) + require.ErrorContains(t, err, "does not conform with uid/gid/mode rules") + }) + + t.Run("rejects group-readable 0640", func(t *testing.T) { + path := writeSecretFile(t, 0o640, contents) + _, err := ReadSecretFile(path) + require.ErrorContains(t, err, "does not conform with uid/gid/mode rules") + }) + + t.Run("rejects 0440 owned by the process user (kubernetes files are root-owned)", func(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("as root, root:root 0440 is the kubernetes form") + } + path := writeSecretFile(t, 0o440, contents) + _, err := ReadSecretFile(path) + require.ErrorContains(t, err, "does not conform with uid/gid/mode rules") + }) + + t.Run("rejects 0440 root:other-group", func(t *testing.T) { + path := writeSecretFile(t, 0o644, contents) + if err := os.Chown(path, 0, 1); err != nil { + t.Skipf("cannot chown to root: %v", err) + } + require.NoError(t, os.Chmod(path, 0o440)) + _, err := ReadSecretFile(path) + require.ErrorContains(t, err, "does not conform with uid/gid/mode rules") + }) + + t.Run("rejects 0400 owned by another user", func(t *testing.T) { + path := writeSecretFile(t, 0o644, contents) + if err := os.Chown(path, 1, 1); err != nil { + t.Skipf("cannot chown to another user: %v", err) + } + require.NoError(t, os.Chmod(path, 0o400)) + _, err := ReadSecretFile(path) + require.ErrorContains(t, err, "does not conform with uid/gid/mode rules") + }) + + t.Run("error does not leak file contents", func(t *testing.T) { + const secret = "TOP-SECRET-VALUE-123" + path := writeSecretFile(t, 0o644, secret) + _, err := ReadSecretFile(path) + require.Error(t, err) + require.NotContains(t, err.Error(), secret) + }) +} + +func TestGetAuthMnemonicFromFile(t *testing.T) { + viper.Reset() + viper.AutomaticEnv() // Reset drops the package init's AutomaticEnv registration + t.Setenv(AUTH_MNEMONIC, "") + t.Setenv(AUTH_MNEMONIC_FILE, writeSecretFile(t, 0o400, "mnemonic words\n")) + mnemonic, err := GetAuthMnemonic() + require.NoError(t, err) + require.Equal(t, "mnemonic words", mnemonic.Value) +} + +func TestGetAuthMnemonicFromFileRejectsInsecurePermissions(t *testing.T) { + skipIfNoPermissionPolicy(t) + viper.Reset() + viper.AutomaticEnv() // Reset drops the package init's AutomaticEnv registration + t.Setenv(AUTH_MNEMONIC, "") + t.Setenv(AUTH_MNEMONIC_FILE, writeSecretFile(t, 0o644, "mnemonic words\n")) + _, err := GetAuthMnemonic() + require.ErrorContains(t, err, "does not conform with uid/gid/mode rules") +} diff --git a/internal/config/secret_file_unix.go b/internal/config/secret_file_unix.go new file mode 100644 index 000000000..5406a17ab --- /dev/null +++ b/internal/config/secret_file_unix.go @@ -0,0 +1,46 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build unix + +package config + +import ( + "fmt" + "io/fs" + "os" + "syscall" +) + +// secretFileOpenFlags opens secret files without blocking (a read-only +// open() on a FIFO or blocking device node would otherwise wait for a +// writer, stalling startup before any log output exists) and without +// following symlinks (open fails with ELOOP instead). +const secretFileOpenFlags = os.O_RDONLY | syscall.O_NONBLOCK | syscall.O_NOFOLLOW + +// checkSecretFilePermissions enforces the secret file permission policy. +// Only two canonical forms are accepted: +// +// - Kubernetes secret volumes: root:fsGroup with mode 0440 (the file is +// root-owned, its group is the process's effective group, and only the +// owner and that group can read it); +// - Compose / host files: owned by the process's user with mode 0400 or +// 0600 (owner-only; the owner write bit allows in-place rotation and +// grants no additional read access). +// +// Anything else is rejected. +func checkSecretFilePermissions(path string, info fs.FileInfo) error { + perm := info.Mode().Perm() + stat := info.Sys().(*syscall.Stat_t) + + // kubernetes secret files: + if stat.Uid == 0 && int(stat.Gid) == os.Getegid() && perm == 0o440 { + return nil + } + + // compose secret files: + if int(stat.Uid) == os.Geteuid() && (perm == 0o600 || perm == 0o400) { + return nil + } + return fmt.Errorf("secret file %q does not conform with uid/gid/mode rules", path) +} diff --git a/internal/config/secret_file_unix_test.go b/internal/config/secret_file_unix_test.go new file mode 100644 index 000000000..7ae2869ba --- /dev/null +++ b/internal/config/secret_file_unix_test.go @@ -0,0 +1,24 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build linux || darwin + +package config + +import ( + "path/filepath" + "syscall" + "testing" + + "github.com/stretchr/testify/require" +) + +// A read-only open() on a FIFO blocks until a writer appears. Config is +// loaded before the logger exists, so a FIFO at a _FILE path must be +// rejected promptly instead of stalling startup with no output. +func TestReadSecretFileRejectsFIFOWithoutBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "fifo") + require.NoError(t, syscall.Mkfifo(path, 0o600)) + _, err := ReadSecretFile(path) + require.ErrorContains(t, err, "not a regular file") +} diff --git a/test/secrets/auth_mnemonic.txt b/test/secrets/auth_mnemonic similarity index 100% rename from test/secrets/auth_mnemonic.txt rename to test/secrets/auth_mnemonic diff --git a/test/secrets/blockchain_http_endpoint.txt b/test/secrets/blockchain_http_endpoint similarity index 100% rename from test/secrets/blockchain_http_endpoint.txt rename to test/secrets/blockchain_http_endpoint diff --git a/test/secrets/database_connection.txt b/test/secrets/database_connection similarity index 100% rename from test/secrets/database_connection.txt rename to test/secrets/database_connection