From 64d8d0e1efa75d0c73bd7f686379cda3b57ea141 Mon Sep 17 00:00:00 2001 From: R0ck Date: Tue, 8 Sep 2026 14:44:36 +0100 Subject: [PATCH] native: the seed goes down as the word the firmware reads 57 of 58 native nodes on Windows booted with one identity, private key included. The bridge parses --seed with strtoul into a uint32_t; where a long is 64 bits the cast keeps the low word, and where it is 32 bits, every Windows target, strtoul saturates at ULONG_MAX, so every node whose seed was above 2^32 was seeded 0xFFFFFFFF. Node 0's seed is the run seed, which fits, and was the one node apart. The seed now goes down as its low 32 bits, which is all the firmware ever read: no identity changes on Linux or macOS, and the published binaries are right without a rebuild. MeshBench/meshcore-native#21 widens the parser for anyone running the binary by hand. Closes #712 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Q9HbD44EKWWTRYgxbFGxf6 --- internal/firmware/native/native.go | 37 ++++++++++----- .../firmware/native/seed_internal_test.go | 46 +++++++++++++++++++ 2 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 internal/firmware/native/seed_internal_test.go diff --git a/internal/firmware/native/native.go b/internal/firmware/native/native.go index 93b3f8ed..74b78686 100644 --- a/internal/firmware/native/native.go +++ b/internal/firmware/native/native.go @@ -108,6 +108,31 @@ func (n *Native) PID() int { return n.cmd.Process.Pid } +// args is the child's command line. +// +// The seed goes down as its low 32 bits. That is all the firmware ever read: +// the bridge parses --seed with strtoul into a uint32_t, and where a long is +// 64 bits the cast keeps the low word. Where a long is 32 bits - every Windows +// target - strtoul saturates at ULONG_MAX instead, so every node whose seed +// was above 2^32 was seeded 0xFFFFFFFF, and 57 of 58 nodes booted with one +// identity, private key included. Node 0's seed is the run seed, which fits, +// and was the one node apart. Masking here gives every platform the low +// word Linux and macOS always had, so no identity changes where the mesh +// already worked, and the published binaries need no rebuild to be right. +func (n *Native) args(bridgeAddr string) []string { + args := []string{"--bridge", bridgeAddr, "--seed", fmt.Sprint(uint32(n.Seed))} + if n.SF != 0 { + args = append(args, "--sf", fmt.Sprint(n.SF)) + } + if n.BandwidthKHz != 0 { + args = append(args, "--bw-khz", strconv.FormatFloat(n.BandwidthKHz, 'f', -1, 64)) + } + if n.CodingRate != 0 { + args = append(args, "--cr", fmt.Sprint(n.CodingRate)) + } + return args +} + func (n *Native) Start(ctx context.Context, bridgeAddr string) (err error) { path, err := firmware.FindNative(n.Path, n.Role) if err != nil { @@ -118,17 +143,7 @@ func (n *Native) Start(ctx context.Context, bridgeAddr string) (err error) { if n.cmd != nil { return errors.New("firmware: native node already started") } - args := []string{"--bridge", bridgeAddr, "--seed", fmt.Sprint(n.Seed)} - if n.SF != 0 { - args = append(args, "--sf", fmt.Sprint(n.SF)) - } - if n.BandwidthKHz != 0 { - args = append(args, "--bw-khz", strconv.FormatFloat(n.BandwidthKHz, 'f', -1, 64)) - } - if n.CodingRate != 0 { - args = append(args, "--cr", fmt.Sprint(n.CodingRate)) - } - cmd := exec.CommandContext(ctx, path, args...) + cmd := exec.CommandContext(ctx, path, n.args(bridgeAddr)...) // The same attributes the emulated backend gives its children: a // parent-death signal where the platform has one, and no console window // on Windows, where every node is a console program and a national diff --git a/internal/firmware/native/seed_internal_test.go b/internal/firmware/native/seed_internal_test.go new file mode 100644 index 00000000..f710a10c --- /dev/null +++ b/internal/firmware/native/seed_internal_test.go @@ -0,0 +1,46 @@ +package native + +import ( + "math" + "strconv" + "testing" +) + +// The engine strides node seeds by a 64-bit golden-ratio constant, so every +// node past the first carries a seed above 2^32. The bridge parses --seed with +// strtoul into a uint32_t, and on Windows, where a long is 32 bits, strtoul +// saturates: 57 of 58 nodes were seeded 0xFFFFFFFF and booted with one +// identity, private key included. What goes down the command line has to be +// a number that parser cannot saturate on, and it has to be the low word +// Linux and macOS were already keeping, so nothing changes where it worked. +func TestTheSeedOnTheCommandLineFitsTheFirmwaresParser(t *testing.T) { + // The engine's stride, as a variable so the arithmetic wraps the way the + // engine's does rather than failing as a constant expression. + var stride uint64 = 0x9E3779B97F4A7C15 + seeds := []uint64{4417, 9001 + 1*stride, 9001 + 57*stride, math.MaxUint64} + for _, seed := range seeds { + want := seed & math.MaxUint32 + n := &Native{Seed: seed} + args := n.args("127.0.0.1:1") + var got string + for i, a := range args { + if a == "--seed" && i+1 < len(args) { + got = args[i+1] + } + } + v, err := strconv.ParseUint(got, 10, 32) + if err != nil { + t.Fatalf("seed %d went down as %q, which a 32-bit parser refuses: %v", seed, got, err) + } + if v != want { + t.Errorf("seed %d went down as %d, want its low word %d", seed, v, want) + } + } + // And two nodes of one run still get two seeds: the stride's low word is + // as distinct as its high one. + a := (&Native{Seed: 9001 + 1*stride}).args("x")[3] + b := (&Native{Seed: 9001 + 2*stride}).args("x")[3] + if a == b { + t.Fatalf("two nodes went down with one seed %s", a) + } +}