-
Notifications
You must be signed in to change notification settings - Fork 7
fix(node): require auth on /node/debug and surface key-file load errors #139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
22aeedb
4075bf0
ffcc816
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -85,8 +85,10 @@ apiPackages: | |
| open: true | ||
| - name: /p2pstatus | ||
| open: true | ||
| # /debug returns cached interceptor and resolver state; keep it behind auth. | ||
| - name: /debug | ||
| open: true | ||
| secured: true | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nothing pins this flag anywhere. No test loads the shipped yaml (config/api_test.go only builds synthetic route configs), and routes_test.go:554 still declares /debug as |
||
| - name: /peerinfo | ||
| open: true | ||
| - name: /statistics | ||
|
|
@@ -114,10 +116,20 @@ apiPackages: | |
| - name: /query | ||
| open: true | ||
|
|
||
| # `password` holds a DIGEST, not the password itself: the hex-encoded hash of the | ||
| # password, using the algorithm named under `hasher` below. A plaintext value here | ||
| # will never authenticate. | ||
| # | ||
| # Generate one with: | ||
| # printf '%s' 'your-password' | sha256sum | cut -d' ' -f1 # GNU coreutils | ||
| # printf '%s' 'your-password' | shasum -a 256 | cut -d' ' -f1 # macOS / perl | ||
| # | ||
| # Anyone who can read this file can attempt an offline crack against these digests, | ||
| # so keep it readable only by the node user, and terminate TLS in front of the API. | ||
| credentials: | ||
| - username: example | ||
| password: hashed password | ||
| password: replace-me-with-a-sha256-digest | ||
| - username: example2 | ||
| password: hashed password | ||
| password: replace-me-with-a-sha256-digest | ||
| hasher: | ||
| type: sha256 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,9 +3,9 @@ package factory | |
| import ( | ||
| "bytes" | ||
| "encoding/hex" | ||
| "errors" | ||
| "fmt" | ||
| "os" | ||
| "strings" | ||
|
|
||
| "github.com/klever-io/klever-go/common" | ||
| "github.com/klever-io/klever-go/core" | ||
|
|
@@ -109,14 +109,27 @@ func (cspf *cryptoSigningParamsLoader) getSkPk() ([]byte, []byte, error) { | |
| skIndex := cspf.skIndex | ||
| encodedSk, pkString, err := tools.LoadSkPkFromPemFile(cspf.skPemFileName, skIndex, os.Getenv("KEY_PASSWORD")) | ||
| if err != nil { | ||
| if strings.Contains(err.Error(), ErrFileNotFound.Error()) { | ||
| keyGen := signing.NewKeyGenerator(cspf.suite) | ||
| encodedSk, pkString, err = tools.CreateWallet(cspf.skPemFileName, os.Getenv("KEY_PASSWORD"), keyGen, cspf.pubkeyConverter) | ||
| if err != nil { | ||
| return nil, nil, err | ||
| } | ||
| // Only a missing file is recoverable by generating a key. Anything else | ||
| // (corrupt pem, wrong KEY_PASSWORD, permissions) must surface here rather | ||
| // than fall through and fail later against an empty key. | ||
| if !isSkPemFileNotFound(err) { | ||
| return nil, nil, fmt.Errorf("loading validator key: %w", err) | ||
| } | ||
|
|
||
| keyGen := signing.NewKeyGenerator(cspf.suite) | ||
| encodedSk, pkString, err = tools.CreateWallet(cspf.skPemFileName, os.Getenv("KEY_PASSWORD"), keyGen, cspf.pubkeyConverter) | ||
| if err != nil { | ||
| return nil, nil, err | ||
| } | ||
|
|
||
| // Generating a key here is intentional: it lets an observer start without | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: this comment says roughly what the |
||
| // operator-provided key material. It is only a problem when the node was | ||
| // meant to run under an already-registered validator identity, which we | ||
| // cannot distinguish at this point, so say so rather than staying silent. | ||
| log.Warn("no key file found - generated a new node identity", | ||
| "file", cspf.skPemFileName, | ||
| "public key", pkString, | ||
| "note", "expected for a new observer; if this node should run as a registered validator, stop it and restore its key file") | ||
| } | ||
|
|
||
| skBytes, err := hex.DecodeString(string(encodedSk)) | ||
|
|
@@ -131,3 +144,11 @@ func (cspf *cryptoSigningParamsLoader) getSkPk() ([]byte, []byte, error) { | |
|
|
||
| return skBytes, pkBytes, nil | ||
| } | ||
|
|
||
| // isSkPemFileNotFound reports whether the key file is simply absent. Matched by | ||
| // type only: a substring match on the not-found text is satisfied by any error | ||
| // carrying a path that happens to contain it, which would send a corrupt key | ||
| // file down the generate-and-replace branch. | ||
| func isSkPemFileNotFound(err error) bool { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: one |
||
| return errors.Is(err, os.ErrNotExist) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| package factory | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any reason for a new file here? cryptoSigningParams_test.go already covers |
||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/klever-io/klever-go/common/mock" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| const corruptPem = "this is not a pem file at all\n" | ||
|
|
||
| func newLoaderFor(t *testing.T, pemPath string) *cryptoSigningParamsLoader { | ||
| t.Helper() | ||
| cspf, err := NewCryptoSigningParamsLoader( | ||
| &mock.PubkeyConverterStub{}, | ||
| 0, | ||
| pemPath, | ||
| &mock.SuiteStub{CreateKeyPairStub: createKeyPair}, | ||
| false, | ||
| ) | ||
| require.NoError(t, err) | ||
| return cspf | ||
| } | ||
|
|
||
| // SAFETY: an existing-but-unloadable pem (corrupt file, wrong KEY_PASSWORD, | ||
| // bad permissions) must NEVER be replaced by a freshly generated key. Doing so | ||
| // would destroy a validator's key material irrecoverably. | ||
| func TestKeyFileSafety_CorruptPemIsNeverOverwritten(t *testing.T) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: this one and the next are near-identical bodies, only the pem's parent directory differs. Could be a single table with two cases, something like "plain dir" and "dir named 'no such file or directory'". If you do that, keep the |
||
| dir := t.TempDir() | ||
| pem := filepath.Join(dir, "validatorKey.pem") | ||
| require.NoError(t, os.WriteFile(pem, []byte(corruptPem), 0o600)) | ||
|
|
||
| _, _, err := newLoaderFor(t, pem).getSkPk() | ||
| require.Error(t, err, "an unloadable pem must surface an error, not be swallowed") | ||
| require.ErrorContains(t, err, "loading validator key", | ||
| "the error must name what failed, not surface as a bare deserialize error") | ||
|
|
||
| after, rerr := os.ReadFile(pem) | ||
| require.NoError(t, rerr) | ||
| require.Equal(t, corruptPem, string(after), | ||
| "CATASTROPHIC: an unloadable pem was overwritten with a generated key") | ||
|
fbsobreira marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // Regression: the not-found check must match by error TYPE, not by searching the | ||
| // message for "no such file or directory". That text appears in every PEM error | ||
| // whose path happens to contain it, which sent a corrupt key file down the | ||
| // generate-and-replace branch and destroyed it. | ||
| func TestKeyFileSafety_NotFoundIsMatchedByTypeNotMessage(t *testing.T) { | ||
| dir := filepath.Join(t.TempDir(), "no such file or directory") | ||
| require.NoError(t, os.MkdirAll(dir, 0o755)) | ||
| pem := filepath.Join(dir, "validatorKey.pem") | ||
| require.NoError(t, os.WriteFile(pem, []byte(corruptPem), 0o600)) | ||
|
|
||
| _, _, err := newLoaderFor(t, pem).getSkPk() | ||
| require.Error(t, err, "a corrupt pem must fail even when its path contains the not-found text") | ||
|
|
||
| after, rerr := os.ReadFile(pem) | ||
| require.NoError(t, rerr) | ||
| require.Equal(t, corruptPem, string(after), | ||
| "CATASTROPHIC: a corrupt pem on a path containing the not-found text was replaced") | ||
| } | ||
|
|
||
| // The intentional observer path: a genuinely absent pem DOES create a key file. | ||
| func TestKeyFileSafety_MissingPemDoesCreateKey(t *testing.T) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: |
||
| dir := t.TempDir() | ||
| pem := filepath.Join(dir, "validatorKey.pem") | ||
|
|
||
| _, _, err := newLoaderFor(t, pem).getSkPk() | ||
| require.NoError(t, err, "observer auto-generation must keep working") | ||
|
|
||
| info, serr := os.Stat(pem) | ||
| require.NoError(t, serr, "a key file should have been created") | ||
| require.Greater(t, info.Size(), int64(0)) | ||
| t.Logf("missing pem -> created %s (%d bytes)", pem, info.Size()) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| package middleware | ||
|
|
||
| import ( | ||
| "crypto/subtle" | ||
| "encoding/hex" | ||
| "net/http" | ||
|
|
||
|
|
@@ -60,7 +61,8 @@ func NewAuthenticationFunc(credentialsConfig config.APIRoutesConfig) gin.Handler | |
| return | ||
| } | ||
|
|
||
| if userPassword != hex.EncodeToString(hasher.Compute(pass)) { | ||
| expected := hex.EncodeToString(hasher.Compute(pass)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Read your reply to Copilot on this, but I'd push back on deferring it, since this PR is exactly what makes this middleware the gate on /node/debug. The branch just above returns
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Separate thing on this line, and not yours: |
||
| if subtle.ConstantTimeCompare([]byte(userPassword), []byte(expected)) != 1 { | ||
|
fbsobreira marked this conversation as resolved.
|
||
| c.AbortWithStatusJSON(http.StatusUnauthorized, shared.GenericAPIResponse{ | ||
| Data: nil, | ||
| Error: "invalid password", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Strictly this only changes the default for fresh installs.
routeHasFlagreturns false when the flag is absent, and upgrading the binary doesn't rewrite an operator's existing api.yaml, so every node already out there keeps /debug open and unauthenticated with no signal that anything changed. Could we log a warn at startup when /debug is open but not secured? api.go:148 already does that for the secured-but-not-open /subscribe case, and open-but-not-secured is the direction that actually leaves state exposed. The required config edit is worth a line in the release notes too.