Files
ids/node_id.go
zeekay e0d883f38b A node id and a short id read text as text, the way an id already did
UnmarshalText delegated to UnmarshalJSON, so it demanded quotes around a value
that was never JSON. Every caller that keeps the TextUnmarshaler contract met
"first and last characters should be quotes": encoding/json handing over a map
KEY, encoding/xml, flag.Value, and a URL binder reading a query value — where
the failure is silent and the node id arrives as the zero node with a 200 on
the answer.

ID.UnmarshalText was corrected this way already, and its comment records the
same symptom on --chain-aliases-file. This is that correction on the other two,
with the reading in ONE place: UnmarshalText holds it and UnmarshalJSON unquotes
and calls it, so the two spellings cannot drift.
2026-08-29 22:46:03 -07:00

126 lines
3.7 KiB
Go

// Copyright (C) 2020-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package ids
import (
"bytes"
"errors"
"fmt"
"github.com/luxfi/crypto/hash"
)
const (
NodeIDPrefix = "NodeID-"
NodeIDLen = ShortIDLen
)
var (
EmptyNodeID = NodeID{}
errShortNodeID = errors.New("insufficient NodeID length")
_ Sortable[NodeID] = NodeID{}
)
type NodeID ShortID
func (id NodeID) String() string {
return ShortID(id).PrefixedString(NodeIDPrefix)
}
func (id NodeID) Bytes() []byte {
return id[:]
}
func (id NodeID) MarshalJSON() ([]byte, error) {
return []byte(`"` + id.String() + `"`), nil
}
func (id NodeID) MarshalText() ([]byte, error) {
return []byte(id.String()), nil
}
// UnmarshalJSON reads the same prefixed word, quoted. The reading itself is in
// UnmarshalText and is not written twice.
func (id *NodeID) UnmarshalJSON(b []byte) error {
str := string(b)
if str == nullStr { // If "null", do nothing
return nil
}
if len(str) < 2 || str[0] != '"' || str[len(str)-1] != '"' {
return errMissingQuotes
}
return id.UnmarshalText([]byte(str[1 : len(str)-1]))
}
// UnmarshalText reads the prefixed word ITSELF, unquoted — which is what a
// TextUnmarshaler is handed, by encoding/json for a map key, by encoding/xml,
// by flag.Value and by a URL binder. It delegated to UnmarshalJSON, so every
// one of those callers met "first and last characters should be quotes" for a
// value that was never JSON, and a node id named in a URL arrived as the zero
// node. [ID.UnmarshalText] was already corrected this way; this is the same
// correction on the other two.
func (id *NodeID) UnmarshalText(text []byte) error {
str := string(text)
if str == nullStr || str == "" {
return nil
}
if len(str) <= len(NodeIDPrefix) {
return fmt.Errorf("%w: expected to be > %d", errShortNodeID, len(NodeIDPrefix))
}
var err error
*id, err = NodeIDFromString(str)
return err
}
func (id NodeID) Compare(other NodeID) int {
return bytes.Compare(id[:], other[:])
}
// ToNodeID attempt to convert a byte slice into a node id
func ToNodeID(bytes []byte) (NodeID, error) {
nodeID, err := ToShortID(bytes)
return NodeID(nodeID), err
}
func NodeIDFromCert(cert *Certificate) NodeID {
return hash.ComputeHash160Array(
hash.ComputeHash256(cert.Raw),
)
}
// NodeIDFromString is the inverse of NodeID.String()
func NodeIDFromString(nodeIDStr string) (NodeID, error) {
asShort, err := ShortFromPrefixedString(nodeIDStr, NodeIDPrefix)
if err != nil {
return NodeID{}, err
}
return NodeID(asShort), nil
}
// NodeIDPrefix for the legacy ML-DSA NodeID derivation. Retained for
// backwards compatibility with chains that registered validators under the
// pre-v1 derivation; new strict-PQ chains use the canonical SHAKE256-384
// derivation in node_id_scheme.go (NodeIDScheme.DeriveMLDSA), which
// domain-separates by chain id AND scheme byte and produces a 48-byte
// commitment whose 20-byte prefix is the NodeID.
const NodeIDMLDSADomainPrefix = "LuxNodeID/v1"
// NodeIDFromMLDSA derives a 20-byte NodeID from an ML-DSA public key
// using the legacy (pre-chain-binding) derivation. Kept stable for
// existing call sites; new strict-PQ chains MUST use
// NodeIDScheme.DeriveMLDSA or TypedNodeIDFromMLDSA, which bind the
// chain id and scheme byte into the digest and return the full
// 48-byte SHAKE256-384 commitment alongside the truncated NodeID.
//
// NodeID = RIPEMD160(SHA256("LuxNodeID/v1" || mldsa_pubkey_bytes))
func NodeIDFromMLDSA(mldsaPubKey []byte) NodeID {
prefix := []byte(NodeIDMLDSADomainPrefix)
data := make([]byte, len(prefix)+len(mldsaPubKey))
copy(data, prefix)
copy(data[len(prefix):], mldsaPubKey)
return hash.ComputeHash160Array(hash.ComputeHash256(data))
}