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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user