decomplect: move SecurityProfile Resolve to pkg/genesis/security

pkg/genesis core types (SecurityProfile struct) stay free of
luxfi/consensus dep. The verification gate (ResolveProfile + hash
comparison) moves to pkg/genesis/security which imports both.

Result: downstream consumers that only need to parse canonical
genesis data (luxd v1.23.x line, tools, indexers) can do so without
dragging luxfi/consensus + luxfi/threshold + luxfi/validators
(Corona→Corona refactor) into their go.mod.

go list -deps ./pkg/genesis → no luxfi/consensus.
go test ./pkg/genesis/security/ → all 5 F102 verification tests pass.

Closes the dep cascade that blocked the v1.23.x backport of
evmAddr/utxoAddr canonical genesis. One and one way only:
data lives in pkg/genesis, verification lives in pkg/genesis/security,
consensus consumes both — never the reverse.

Closes #93 Phase 1.
This commit is contained in:
Hanzo AI
2026-05-30 14:33:58 -07:00
parent f0f5aee8eb
commit 806d0ec441
3 changed files with 140 additions and 115 deletions
+107
View File
@@ -0,0 +1,107 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package security implements the verification gate for a
// genesis-pinned ChainSecurityProfile. It is the ONLY package in
// luxfi/genesis that imports luxfi/consensus — the rest of pkg/genesis
// stays consensus-dep-free so downstream tools that only need to parse
// canonical genesis data can do so without dragging the consensus engine
// (and its post-quantum threshold deps) into their go.mod.
//
// Wire form is pin-by-ID + pin-by-hash:
// - ProfileID names the canonical ChainSecurityProfile (e.g. 0x01 =
// StrictPQ).
// - ProfileHashHex is the 48-byte SHA3-384 ComputeHash of the
// canonical profile, hex-encoded. ResolveProfile recomputes the
// hash at boot from consensusconfig.ProfileByID(ProfileID) and
// refuses to start if the hex does not match. Any drift in the
// canonical profile content invalidates every prior genesis that
// pinned its hash.
//
// One-way load: deserialise → resolve → validate → ComputeHash →
// constant-time compare. There is no upgrade path that re-derives the
// hash from the pinned ID alone; that would defeat the lock.
//
// Closes red-team finding F102.
package security
import (
"encoding/hex"
"errors"
"fmt"
consensusconfig "github.com/luxfi/consensus/config"
"github.com/luxfi/genesis/pkg/genesis"
)
// Errors returned by ResolveProfile when the pin fails to resolve to a
// known canonical profile or its content hash mismatches.
var (
ErrSecurityProfileMissing = errors.New("genesis/security: security profile pin is absent on a profile-required genesis")
ErrSecurityProfileInvalidID = errors.New("genesis/security: SecurityProfile.ProfileID is not a known consensus profile")
ErrSecurityProfileInvalidHashHex = errors.New("genesis/security: SecurityProfile.ProfileHashHex is not 48 bytes of hex")
ErrSecurityProfileValidateFailed = errors.New("genesis/security: canonical profile failed Validate() at boot")
ErrSecurityProfileHashMismatch = errors.New("genesis/security: live ComputeHash() does not match genesis-pinned ProfileHashHex")
ErrSecurityProfileComputeHashFail = errors.New("genesis/security: canonical profile ComputeHash() returned error")
)
// ResolveProfile loads the canonical ChainSecurityProfile named by
// ProfileID, validates it, computes its hash, and confirms the hash
// matches the genesis pin. Returns the canonical profile on success.
//
// This is the single load-and-verify entry point. Every consumer of
// the locked profile (node bootstrap, signer registration, peer-cert
// gate, mempool, validator registry, bridge oracle) calls ResolveProfile
// once at startup and threads the returned *ChainSecurityProfile
// through the rest of the process — no re-resolution per request.
//
// The constant-time hash comparison is intentional: a runtime mutation
// of the canonical profile that produces a different hash MUST fail
// the genesis pin, and we don't want to leak which byte of the hash
// matched via an early-exit byte loop. (Hash comparisons are not
// secrets, but consistency with the rest of the security gate is
// worth more than the four-line shortcut.)
func ResolveProfile(s *genesis.SecurityProfile) (*consensusconfig.ChainSecurityProfile, error) {
if s == nil {
return nil, ErrSecurityProfileMissing
}
pinned, err := hex.DecodeString(s.ProfileHashHex)
if err != nil || len(pinned) != 48 {
return nil, fmt.Errorf("%w: have %d bytes (%v)", ErrSecurityProfileInvalidHashHex, len(pinned), err)
}
profile, err := consensusconfig.ProfileByID(consensusconfig.ProfileID(s.ProfileID))
if err != nil {
return nil, fmt.Errorf("%w: 0x%02x: %v", ErrSecurityProfileInvalidID, s.ProfileID, err)
}
if err := profile.Validate(); err != nil {
return nil, fmt.Errorf("%w: %v", ErrSecurityProfileValidateFailed, err)
}
live, err := profile.ComputeHash()
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrSecurityProfileComputeHashFail, err)
}
if !constantTimeEqual48(live[:], pinned) {
return nil, fmt.Errorf("%w: pinned=%s live=%x",
ErrSecurityProfileHashMismatch, s.ProfileHashHex, live[:])
}
// Stamp the live hash so consumers always see it on the struct.
profile.ProfileHash = live
return profile, nil
}
// constantTimeEqual48 returns true iff the two 48-byte hashes are equal,
// in constant time over the byte slice contents. Subtle.ConstantTimeCompare
// is generic over byte slices but pinned to 48 bytes here so a length
// mismatch is caught explicitly above.
func constantTimeEqual48(a, b []byte) bool {
if len(a) != 48 || len(b) != 48 {
return false
}
var v byte
for i := 0; i < 48; i++ {
v |= a[i] ^ b[i]
}
return v == 0
}
@@ -1,7 +1,7 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package genesis
package security
import (
"encoding/hex"
@@ -11,6 +11,7 @@ import (
"testing"
consensusconfig "github.com/luxfi/consensus/config"
"github.com/luxfi/genesis/pkg/genesis"
)
// TestSecurityProfile_Resolve_StrictPQ proves a genesis pin for the
@@ -25,11 +26,11 @@ func TestSecurityProfile_Resolve_StrictPQ(t *testing.T) {
t.Fatalf("StrictPQ().ComputeHash() returned error: %v", err)
}
pin := &SecurityProfile{
pin := &genesis.SecurityProfile{
ProfileID: uint8(consensusconfig.ProfileStrictPQ),
ProfileHashHex: hex.EncodeToString(live[:]),
}
got, err := pin.Resolve()
got, err := ResolveProfile(pin)
if err != nil {
t.Fatalf("Resolve() returned error: %v", err)
}
@@ -52,11 +53,11 @@ func TestSecurityProfile_Resolve_StrictPQ(t *testing.T) {
// boot.
func TestSecurityProfile_Resolve_HashMismatchRejected(t *testing.T) {
// Pin a wrong hash for StrictPQ.
pin := &SecurityProfile{
pin := &genesis.SecurityProfile{
ProfileID: uint8(consensusconfig.ProfileStrictPQ),
ProfileHashHex: strings.Repeat("00", 48), // 96 hex zeros
}
_, err := pin.Resolve()
_, err := ResolveProfile(pin)
if err == nil {
t.Fatal("Resolve() accepted a wrong-hash pin")
}
@@ -68,11 +69,11 @@ func TestSecurityProfile_Resolve_HashMismatchRejected(t *testing.T) {
// TestSecurityProfile_Resolve_UnknownProfileIDRejected proves the
// pin refuses an unknown ProfileID byte.
func TestSecurityProfile_Resolve_UnknownProfileIDRejected(t *testing.T) {
pin := &SecurityProfile{
pin := &genesis.SecurityProfile{
ProfileID: 0xFE, // unregistered
ProfileHashHex: strings.Repeat("ab", 48),
}
_, err := pin.Resolve()
_, err := ResolveProfile(pin)
if err == nil {
t.Fatal("Resolve() accepted an unknown ProfileID")
}
@@ -93,11 +94,11 @@ func TestSecurityProfile_Resolve_InvalidHashHexRejected(t *testing.T) {
"wrong padding": "0x" + strings.Repeat("ab", 48), // hex.DecodeString refuses 0x prefix
}
for name, hashHex := range cases {
pin := &SecurityProfile{
pin := &genesis.SecurityProfile{
ProfileID: uint8(consensusconfig.ProfileStrictPQ),
ProfileHashHex: hashHex,
}
_, err := pin.Resolve()
_, err := ResolveProfile(pin)
if err == nil {
t.Errorf("%s: Resolve() accepted an invalid hash hex %q", name, hashHex)
continue
@@ -111,15 +112,15 @@ func TestSecurityProfile_Resolve_InvalidHashHexRejected(t *testing.T) {
// TestSecurityProfile_Resolve_NilRejected proves a missing pin is
// detected at the API boundary.
func TestSecurityProfile_Resolve_NilRejected(t *testing.T) {
var pin *SecurityProfile
_, err := pin.Resolve()
var pin *genesis.SecurityProfile
_, err := ResolveProfile(pin)
if !errors.Is(err, ErrSecurityProfileMissing) {
t.Errorf("nil pin: Resolve() returned %v; want ErrSecurityProfileMissing", err)
}
}
// TestConfig_SecurityProfile_JSONRoundtrip proves the SecurityProfile
// field survives a Config → ConfigOutput → JSON → ConfigOutput round
// field survives a Config → genesis.ConfigOutput → JSON → genesis.ConfigOutput round
// trip. Closes the wire-form half of F102.
func TestConfig_SecurityProfile_JSONRoundtrip(t *testing.T) {
canonical := consensusconfig.StrictPQ()
@@ -127,12 +128,12 @@ func TestConfig_SecurityProfile_JSONRoundtrip(t *testing.T) {
if err != nil {
t.Fatalf("ComputeHash: %v", err)
}
pin := &SecurityProfile{
pin := &genesis.SecurityProfile{
ProfileID: uint8(consensusconfig.ProfileStrictPQ),
ProfileHashHex: hex.EncodeToString(live[:]),
}
out := &ConfigOutput{
out := &genesis.ConfigOutput{
NetworkID: 12345,
Message: "test",
SecurityProfile: pin,
@@ -145,7 +146,7 @@ func TestConfig_SecurityProfile_JSONRoundtrip(t *testing.T) {
t.Fatalf("marshalled output missing securityProfile field: %s", raw)
}
var got ConfigOutput
var got genesis.ConfigOutput
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
@@ -158,7 +159,7 @@ func TestConfig_SecurityProfile_JSONRoundtrip(t *testing.T) {
if got.SecurityProfile.ProfileHashHex != pin.ProfileHashHex {
t.Errorf("ProfileHashHex = %q; want %q", got.SecurityProfile.ProfileHashHex, pin.ProfileHashHex)
}
if _, err := got.SecurityProfile.Resolve(); err != nil {
t.Errorf("Resolve() after round-trip returned %v", err)
if _, err := ResolveProfile(got.SecurityProfile); err != nil {
t.Errorf("ResolveProfile() after round-trip returned %v", err)
}
}
+15 -98
View File
@@ -3,37 +3,25 @@
package genesis
import (
"encoding/hex"
"errors"
"fmt"
consensusconfig "github.com/luxfi/consensus/config"
)
// security_profile.go — genesis-level binding of the chain-wide
// ChainSecurityProfile. Closes red-team finding F102: previously
// neither this package nor luxfi/node imported the locked profile,
// so the profile was pure documentation that no consumer checked.
// security_profile.go — pure-data carriers for the chain-wide
// ChainSecurityProfile pin. The Resolve / Validate / ComputeHash gate
// that actually consults luxfi/consensus lives in pkg/genesis/security
// to keep this package consensus-dep-free (one and only one direction:
// consensus consumes genesis, never the other way).
//
// Wire form is pin-by-ID + pin-by-hash:
// - ProfileID names the canonical ChainSecurityProfile (e.g. 0x01 =
// StrictPQ).
// - ProfileHashHex is the 48-byte SHA3-384 ComputeHash of the
// canonical profile, hex-encoded. The genesis loader recomputes
// the hash at boot from consensusconfig.ProfileByID(ProfileID) and
// refuses to start if the hex does not match. Any drift in the
// canonical profile content invalidates every prior genesis that
// pinned its hash.
//
// One-way load: deserialise → resolve → validate → ComputeHash →
// constant-time compare. There is no upgrade path that re-derives the
// hash from the pinned ID alone; that would defeat the lock.
// canonical profile, hex-encoded. The verifier in
// pkg/genesis/security recomputes the hash at boot and refuses to
// start if the hex does not match. Any drift in the canonical
// profile content invalidates every prior genesis that pinned its
// hash.
// SecurityProfile is the genesis-level pin for a chain's locked
// ChainSecurityProfile. ProfileID identifies the canonical row in the
// consensusconfig registry; ProfileHashHex pins the expected content
// hash. Both fields are mandatory on a profile-bound genesis.
// ChainSecurityProfile. Pure data — no methods that touch consensus.
// Verification lives in pkg/genesis/security.
type SecurityProfile struct {
// ProfileID is the wire byte that names the canonical profile.
// 0x01 = StrictPQ, 0x02 = Permissive, 0x03 = FIPS.
@@ -43,79 +31,8 @@ type SecurityProfile struct {
// ProfileHashHex is the SHA3-384 ComputeHash of the canonical
// ChainSecurityProfile at the time genesis was sealed, hex-encoded
// (96 hex chars, no 0x prefix). The genesis loader rejects a
// startup whose live ComputeHash does not match.
// (96 hex chars, no 0x prefix). The verifier in
// pkg/genesis/security rejects a startup whose live ComputeHash
// does not match.
ProfileHashHex string `json:"profileHashHex"`
}
// SecurityProfileLoadErr is returned by Resolve when the pin fails to
// resolve to a known canonical profile or its content hash mismatches.
var (
ErrSecurityProfileMissing = errors.New("genesis: security profile pin is absent on a profile-required genesis")
ErrSecurityProfileInvalidID = errors.New("genesis: SecurityProfile.ProfileID is not a known consensus profile")
ErrSecurityProfileInvalidHashHex = errors.New("genesis: SecurityProfile.ProfileHashHex is not 48 bytes of hex")
ErrSecurityProfileValidateFailed = errors.New("genesis: canonical profile failed Validate() at boot")
ErrSecurityProfileHashMismatch = errors.New("genesis: live ComputeHash() does not match genesis-pinned ProfileHashHex")
ErrSecurityProfileComputeHashFail = errors.New("genesis: canonical profile ComputeHash() returned error")
)
// Resolve loads the canonical ChainSecurityProfile named by ProfileID,
// validates it, computes its hash, and confirms the hash matches the
// genesis pin. Returns the canonical profile on success.
//
// This is the single load-and-verify entry point. Every consumer of
// the locked profile (node bootstrap, signer registration, peer-cert
// gate, mempool, validator registry, bridge oracle) calls Resolve once
// at startup and threads the returned *ChainSecurityProfile through
// the rest of the process — no re-resolution per request.
//
// The constant-time hash comparison is intentional: a runtime mutation
// of the canonical profile that produces a different hash MUST fail
// the genesis pin, and we don't want to leak which byte of the hash
// matched via an early-exit byte loop. (Hash comparisons are not
// secrets, but consistency with the rest of the security gate is
// worth more than the four-line shortcut.)
func (s *SecurityProfile) Resolve() (*consensusconfig.ChainSecurityProfile, error) {
if s == nil {
return nil, ErrSecurityProfileMissing
}
pinned, err := hex.DecodeString(s.ProfileHashHex)
if err != nil || len(pinned) != 48 {
return nil, fmt.Errorf("%w: have %d bytes (%v)", ErrSecurityProfileInvalidHashHex, len(pinned), err)
}
profile, err := consensusconfig.ProfileByID(consensusconfig.ProfileID(s.ProfileID))
if err != nil {
return nil, fmt.Errorf("%w: 0x%02x: %v", ErrSecurityProfileInvalidID, s.ProfileID, err)
}
if err := profile.Validate(); err != nil {
return nil, fmt.Errorf("%w: %v", ErrSecurityProfileValidateFailed, err)
}
live, err := profile.ComputeHash()
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrSecurityProfileComputeHashFail, err)
}
if !constantTimeEqual48(live[:], pinned) {
return nil, fmt.Errorf("%w: pinned=%s live=%x",
ErrSecurityProfileHashMismatch, s.ProfileHashHex, live[:])
}
// Stamp the live hash so consumers always see it on the struct.
profile.ProfileHash = live
return profile, nil
}
// constantTimeEqual48 returns true iff the two 48-byte hashes are equal,
// in constant time over the byte slice contents. Subtle.ConstantTimeCompare
// is generic over byte slices but pinned to 48 bytes here so a length
// mismatch is caught explicitly above.
func constantTimeEqual48(a, b []byte) bool {
if len(a) != 48 || len(b) != 48 {
return false
}
var v byte
for i := 0; i < 48; i++ {
v |= a[i] ^ b[i]
}
return v == 0
}