From 7bfe3b23669541a09d80c6cb3d1de1f22aba81e8 Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Tue, 28 Apr 2026 16:59:33 +1000 Subject: [PATCH] fix: pin HashMAC first octet to 0x02 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous impl forced only the IEEE 802 reserved bits (bit0=0 unicast, bit1=1 LAA) on the first octet, leaving the upper 6 bits hash-derived. Technically valid LAA unicast, but produced MACs like 4a:.. and fe:.. that visually encroach on vendor OUI space and tripped CI/ops checks expecting all generated MACs to start with 02:. Pin byte 0 to literal 0x02; hash bytes 1-5 from sha256[0:5]. Output entropy drops from 46 to 40 bits — birthday-paradox 1% collision at ~150k ids, still 256x the old 24-bit bound (~580). --- spinifex/utils/utils.go | 12 +++++++----- spinifex/utils/utils_test.go | 24 ++++++------------------ 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/spinifex/utils/utils.go b/spinifex/utils/utils.go index 0d6067856..f655ee12a 100644 --- a/spinifex/utils/utils.go +++ b/spinifex/utils/utils.go @@ -305,9 +305,11 @@ func humanBytes(b uint64) string { } // HashMAC returns a deterministic locally-administered unicast MAC derived -// from id. Output is "02:xx:xx:xx:xx:xx" with 46 bits of SHA-256-derived -// entropy in the low bits of the first octet plus the next 5 octets, and -// the IEEE 802 reserved bits forced (bit0=0 unicast, bit1=1 LAA). +// from id. Output is always "02:xx:xx:xx:xx:xx" — first octet is pinned to +// 0x02 (bit0=0 unicast, bit1=1 LAA, all other bits zero) so generated MACs +// are immediately recognisable as ours and cannot collide with registered +// vendor OUI space. The remaining 40 bits are SHA-256-derived. Birthday- +// paradox 1% collision at ~150k ids, 50% at ~1.2M. // // id must be globally unique across all MACs the deployment produces. No // per-deployment salt is mixed in. Callers that share a base id between @@ -317,8 +319,8 @@ func humanBytes(b uint64) string { func HashMAC(id string) string { sum := sha256.Sum256([]byte(id)) b := make([]byte, 6) - copy(b, sum[:6]) - b[0] = (b[0] & 0xFC) | 0x02 // bit0=0 unicast, bit1=1 locally-administered + b[0] = 0x02 + copy(b[1:], sum[:5]) return net.HardwareAddr(b).String() } diff --git a/spinifex/utils/utils_test.go b/spinifex/utils/utils_test.go index 4435dc521..46984b94e 100644 --- a/spinifex/utils/utils_test.go +++ b/spinifex/utils/utils_test.go @@ -1004,27 +1004,15 @@ func TestReadPidFileFrom_EmptyDir(t *testing.T) { assert.Error(t, err) } -func TestHashMAC_FirstOctetVariesByInput(t *testing.T) { - // Old 24-bit impl pinned first octet to literal prefix byte (always - // 0x02). New 46-bit impl derives first octet from the hash, so it - // spans many values across distinct inputs. - seen := map[byte]struct{}{} +func TestHashMAC_FirstOctetPinnedTo02(t *testing.T) { + // First octet must be literal 0x02 across all inputs. Anything else + // (e.g. 0x4a, 0xfe — technically valid LAA) visually encroaches on + // vendor OUI space and is rejected by ops/CI. Second-octet entropy is + // covered by the distribution test. for i := range 1000 { hw, err := net.ParseMAC(HashMAC(fmt.Sprintf("id-%d", i))) require.NoError(t, err) - seen[hw[0]] = struct{}{} - } - assert.Greater(t, len(seen), 10, "first octet should vary across inputs") -} - -func TestHashMAC_LocallyAdministered(t *testing.T) { - // First octet must always have bit0=0 (unicast) and bit1=1 (LAA) per - // IEEE 802 reserved bits, regardless of hash output. - for i := range 1000 { - hw, err := net.ParseMAC(HashMAC(fmt.Sprintf("id-%d", i))) - require.NoError(t, err) - assert.Equal(t, byte(0x02), hw[0]&0x03, - "first octet %#x must be unicast+LAA", hw[0]) + assert.Equal(t, byte(0x02), hw[0], "first octet must be 0x02, got %#x", hw[0]) } }