fix(node): fail loudly when key file exists but is unreadable

Operator hit this in the wild: keys/node.json mounted into a container
as 600 root:root while the node process runs as an unprivileged user.
os.ReadFile returned a permission error, loadOrCreateIdentity fell
through to "generate a new identity", and genesis allocation (21M
tokens) was credited to the auto-generated key — which then vanished
when the container restarted because the read-only mount also
couldn't be written.

The symptom was a 0-balance import: operators extracted node.json
from the host keys dir, imported it into the mobile client, and
wondered why the genesis validator's wallet was empty.

Fix: distinguish "file doesn't exist" (first boot, generate) from
"file exists but can't be read" (operator error, log.Fatalf with a
hint about permissions / read-only mount). Also fail loudly on JSON
parse errors and decode errors instead of silently generating.

When the new-identity path is taken and the save fails (read-only
mount), the warning now explicitly says the key is ephemeral and the
node's identity will change on restart — operators can catch this
before genesis commits to a throwaway key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vsecoder
2026-04-18 23:04:53 +03:00
parent f726587ac6
commit 6ed4e7ca50

View File

@@ -1291,23 +1291,53 @@ type keyJSON struct {
} }
func loadOrCreateIdentity(keyFile string) *identity.Identity { func loadOrCreateIdentity(keyFile string) *identity.Identity {
if data, err := os.ReadFile(keyFile); err == nil { // Key-file handling has a silent-failure mode that cost a genesis
// validator 21M tokens in the wild: if the file exists but we can't
// read it (e.g. mounted read-only under a different UID), ReadFile
// returns an error, we fall through to "generate", and the operator
// ends up with an ephemeral key whose pubkey doesn't match what's in
// keys/node.json on disk. Genesis allocation then lands on the
// ephemeral key that vanishes on restart.
//
// Distinguish "file doesn't exist" (normal — first boot, create)
// from "file exists but unreadable" (operator error — fail loudly).
if info, err := os.Stat(keyFile); err == nil {
// File is there. Any read failure now is an operator problem,
// not a bootstrap case.
_ = info
data, err := os.ReadFile(keyFile)
if err != nil {
log.Fatalf("[NODE] key file %s exists but can't be read: %v\n"+
"\thint: check file perms (should be readable by the node user) "+
"and that the mount isn't unexpectedly read-only.",
keyFile, err)
}
var kj keyJSON var kj keyJSON
if err := json.Unmarshal(data, &kj); err == nil { if err := json.Unmarshal(data, &kj); err != nil {
if id, err := identity.FromHexFull(kj.PubKey, kj.PrivKey, kj.X25519Pub, kj.X25519Priv); err == nil { log.Fatalf("[NODE] key file %s is not valid JSON: %v", keyFile, err)
// If the file is missing X25519 keys, backfill and re-save. }
if kj.X25519Pub == "" { id, err := identity.FromHexFull(kj.PubKey, kj.PrivKey, kj.X25519Pub, kj.X25519Priv)
kj.X25519Pub = id.X25519PubHex() if err != nil {
kj.X25519Priv = id.X25519PrivHex() log.Fatalf("[NODE] key file %s is valid JSON but identity decode failed: %v",
if out, err2 := json.MarshalIndent(kj, "", " "); err2 == nil { keyFile, err)
_ = os.WriteFile(keyFile, out, 0600) }
} // If the file is missing X25519 keys, backfill and re-save (best-effort,
} // ignore write failure on read-only mounts).
log.Printf("[NODE] loaded identity from %s", keyFile) if kj.X25519Pub == "" {
return id kj.X25519Pub = id.X25519PubHex()
kj.X25519Priv = id.X25519PrivHex()
if out, err2 := json.MarshalIndent(kj, "", " "); err2 == nil {
_ = os.WriteFile(keyFile, out, 0600)
} }
} }
log.Printf("[NODE] loaded identity from %s", keyFile)
return id
} else if !os.IsNotExist(err) {
// Something other than "file not found" — permission on the
// containing directory, broken symlink, etc. Also fail loudly.
log.Fatalf("[NODE] stat %s: %v", keyFile, err)
} }
// File genuinely doesn't exist — first boot. Generate + save.
id, err := identity.Generate() id, err := identity.Generate()
if err != nil { if err != nil {
log.Fatalf("generate identity: %v", err) log.Fatalf("generate identity: %v", err)
@@ -1320,7 +1350,9 @@ func loadOrCreateIdentity(keyFile string) *identity.Identity {
} }
data, _ := json.MarshalIndent(kj, "", " ") data, _ := json.MarshalIndent(kj, "", " ")
if err := os.WriteFile(keyFile, data, 0600); err != nil { if err := os.WriteFile(keyFile, data, 0600); err != nil {
log.Printf("[NODE] warning: could not save key: %v", err) log.Printf("[NODE] warning: could not save key to %s: %v "+
"(ephemeral key in use — this node's identity will change on restart!)",
keyFile, err)
} else { } else {
log.Printf("[NODE] new identity saved to %s", keyFile) log.Printf("[NODE] new identity saved to %s", keyFile)
} }