mirror of
https://github.com/luxfi/genesis.git
synced 2026-07-27 05:54:08 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user