From 6ed4e7ca50f35c4ca3db763c1ded922bcbee55e7 Mon Sep 17 00:00:00 2001 From: vsecoder Date: Sat, 18 Apr 2026 23:04:53 +0300 Subject: [PATCH] fix(node): fail loudly when key file exists but is unreadable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cmd/node/main.go | 60 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/cmd/node/main.go b/cmd/node/main.go index 4902c29..70f602c 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -1291,23 +1291,53 @@ type keyJSON struct { } 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 - if err := json.Unmarshal(data, &kj); err == nil { - if id, err := identity.FromHexFull(kj.PubKey, kj.PrivKey, kj.X25519Pub, kj.X25519Priv); err == nil { - // If the file is missing X25519 keys, backfill and re-save. - if kj.X25519Pub == "" { - 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 + if err := json.Unmarshal(data, &kj); err != nil { + log.Fatalf("[NODE] key file %s is not valid JSON: %v", keyFile, err) + } + id, err := identity.FromHexFull(kj.PubKey, kj.PrivKey, kj.X25519Pub, kj.X25519Priv) + if err != nil { + log.Fatalf("[NODE] key file %s is valid JSON but identity decode failed: %v", + keyFile, err) + } + // If the file is missing X25519 keys, backfill and re-save (best-effort, + // ignore write failure on read-only mounts). + if kj.X25519Pub == "" { + 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() if err != nil { log.Fatalf("generate identity: %v", err) @@ -1320,7 +1350,9 @@ func loadOrCreateIdentity(keyFile string) *identity.Identity { } data, _ := json.MarshalIndent(kj, "", " ") 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 { log.Printf("[NODE] new identity saved to %s", keyFile) }