Files
zeekay 75f7c1dca3 profile: PQ-native signing-profile gate (ML-DSA-65 default)
Adds pkg/profile — the single function `profile.Verify(policy, scheme,
pub, msg, sig)` that gates every intra-Lux operator-receipt signature.
ML-DSA-65 (FIPS 204, NIST Level 3) is the default scheme; Ed25519 is
opt-in only via Policy.LegacyClassicalEnabled and refused otherwise
with ErrClassicalRefused before any classical math runs.

Domain-separation context "luxfi.relay.v1" is bound into every
signature (FIPS 204 §5.2 ctx for ML-DSA-65; prepended bytes for
Ed25519) so a relay receipt cannot be replayed as an oracle
observation (which uses "luxfi.oracle.v1") or any other Lux artifact.

5 tests cover happy path, strict-PQ classical refusal, legacy opt-in,
tampered-sig rejection, and unknown-scheme rejection.

External-chain interactions (Bitcoin / Ethereum / OP_NET RPC,
FROST/Taproot handoff to luxfi/mpc) are NOT in this package's scope
and continue using each chain's native primitive.
2026-05-19 08:36:26 -07:00

201 lines
6.3 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package profile carries the relay's signing-profile decision in ONE place.
//
// Decomplecting principle: the relay has TWO distinct signing surfaces.
//
// 1. Intra-Lux operator surface — receipts, channel attestations, operator
// authentication to R-Chain RPC. Default = ML-DSA-65 (FIPS 204, NIST
// Level 3). Classical Ed25519 is opt-in only via LegacyClassicalEnabled.
//
// 2. External-chain surface — Bitcoin RPC, Ethereum RPC, OP_NET, FROST /
// Taproot threshold signing handed off to luxfi/mpc. These are NOT
// subject to the PQ default; they must conform to the target chain's
// native primitive (secp256k1 ECDSA, schnorr-BIP340, ED25519). The relay
// never PQ-flips an external transaction.
//
// All policy lives here; primitives must never re-decide the profile.
package profile
import (
"crypto/ed25519"
"crypto/rand"
"errors"
"fmt"
"io"
"github.com/luxfi/crypto/mldsa"
)
// Scheme identifies the operator signing scheme. Wire-stable enum.
type Scheme uint8
const (
// SchemeMLDSA65 — ML-DSA-65 (FIPS 204). Default for intra-Lux operator
// signatures (relayd → R-Chain RPC, signed receipts, channel attestations).
SchemeMLDSA65 Scheme = 0x01
// SchemeEd25519 — Classical Ed25519. Opt-in only via Policy.LegacyClassicalEnabled.
SchemeEd25519 Scheme = 0x02
)
func (s Scheme) String() string {
switch s {
case SchemeMLDSA65:
return "ml-dsa-65"
case SchemeEd25519:
return "ed25519"
default:
return fmt.Sprintf("scheme(0x%02x)", uint8(s))
}
}
// Policy carries the relay operator's signing-profile decision.
//
// Default value (zero-Policy) means: ML-DSA-65 only, classical refused.
// This is intentional — the safe default is strict-PQ.
type Policy struct {
// LegacyClassicalEnabled, when true, allows Ed25519 keys and verifies
// classical receipts. Production deployments inside Lux should leave
// this off.
LegacyClassicalEnabled bool
}
// Default returns the strict-PQ policy: ML-DSA-65 only.
func Default() Policy { return Policy{} }
// Permit reports whether a scheme is currently accepted under p.
// This is the single function classical primitives consult before doing
// anything with classical key material.
func (p Policy) Permit(s Scheme) error {
switch s {
case SchemeMLDSA65:
return nil
case SchemeEd25519:
if !p.LegacyClassicalEnabled {
return ErrClassicalRefused
}
return nil
default:
return fmt.Errorf("profile: unknown scheme %s", s)
}
}
// ContextTag is the domain-separation tag bound into every operator
// signature so a Relay receipt cannot be replayed as some other Lux
// artifact and vice versa.
const ContextTag = "luxfi.relay.v1"
// Default key/sig sizes for the active default scheme (ML-DSA-65).
const (
MLDSA65PublicKeySize = mldsa.MLDSA65PublicKeySize
MLDSA65SignatureSize = mldsa.MLDSA65SignatureSize
)
// ErrClassicalRefused is returned when an Ed25519 signature is presented
// under a strict-PQ policy.
var ErrClassicalRefused = errors.New("profile: classical scheme refused under strict-PQ")
// Signer carries an operator's signing key. Construction picks the scheme;
// Sign always domain-separates with ContextTag.
type Signer struct {
scheme Scheme
mldsa *mldsa.PrivateKey
ed ed25519.PrivateKey
}
// NewMLDSA65Signer returns a fresh ML-DSA-65 signer.
func NewMLDSA65Signer(rng io.Reader) (*Signer, error) {
if rng == nil {
rng = rand.Reader
}
sk, err := mldsa.GenerateKey(rng, mldsa.MLDSA65)
if err != nil {
return nil, err
}
return &Signer{scheme: SchemeMLDSA65, mldsa: sk}, nil
}
// NewMLDSA65SignerFromBytes restores an ML-DSA-65 signer from its serialised
// secret-key bytes.
func NewMLDSA65SignerFromBytes(skBytes []byte) (*Signer, error) {
sk, err := mldsa.PrivateKeyFromBytes(mldsa.MLDSA65, skBytes)
if err != nil {
return nil, err
}
return &Signer{scheme: SchemeMLDSA65, mldsa: sk}, nil
}
// NewEd25519Signer wraps a classical Ed25519 key. Callers must have already
// consulted Policy.Permit(SchemeEd25519); this constructor does not.
func NewEd25519Signer(sk ed25519.PrivateKey) *Signer {
return &Signer{scheme: SchemeEd25519, ed: sk}
}
// Scheme returns the underlying scheme tag.
func (s *Signer) Scheme() Scheme { return s.scheme }
// PublicKey returns the serialised public key bytes.
func (s *Signer) PublicKey() []byte {
switch s.scheme {
case SchemeMLDSA65:
return s.mldsa.PublicKey.Bytes()
case SchemeEd25519:
return s.ed.Public().(ed25519.PublicKey)
default:
return nil
}
}
// Sign produces a signature over msg with the relay's domain-separation
// context (FIPS 204 §5.2 ctx for ML-DSA, prepended-tag for Ed25519).
func (s *Signer) Sign(msg []byte) ([]byte, error) {
switch s.scheme {
case SchemeMLDSA65:
return s.mldsa.SignCtx(rand.Reader, msg, []byte(ContextTag))
case SchemeEd25519:
// Ed25519 has no native ctx; we prepend the tag.
tagged := make([]byte, 0, len(ContextTag)+len(msg))
tagged = append(tagged, ContextTag...)
tagged = append(tagged, msg...)
return ed25519.Sign(s.ed, tagged), nil
default:
return nil, fmt.Errorf("profile: signer scheme %s not implemented", s.scheme)
}
}
// Verify checks sig over msg under pub for the given scheme, gated by p.
// This is the SINGLE place all relay receipt-verification flows through.
func Verify(p Policy, scheme Scheme, pub, msg, sig []byte) error {
if err := p.Permit(scheme); err != nil {
return err
}
switch scheme {
case SchemeMLDSA65:
if len(pub) != MLDSA65PublicKeySize {
return fmt.Errorf("profile: ml-dsa-65 pubkey size %d != %d", len(pub), MLDSA65PublicKeySize)
}
pk, err := mldsa.PublicKeyFromBytes(pub, mldsa.MLDSA65)
if err != nil {
return err
}
if !pk.VerifySignatureCtx(msg, sig, []byte(ContextTag)) {
return errors.New("profile: ml-dsa-65 signature invalid")
}
return nil
case SchemeEd25519:
if len(pub) != ed25519.PublicKeySize {
return fmt.Errorf("profile: ed25519 pubkey size %d != %d", len(pub), ed25519.PublicKeySize)
}
tagged := make([]byte, 0, len(ContextTag)+len(msg))
tagged = append(tagged, ContextTag...)
tagged = append(tagged, msg...)
if !ed25519.Verify(ed25519.PublicKey(pub), tagged, sig) {
return errors.New("profile: ed25519 signature invalid")
}
return nil
default:
return fmt.Errorf("profile: unknown scheme %s", scheme)
}
}