mirror of
https://github.com/luxfi/keys.git
synced 2026-07-26 23:58:11 +00:00
service_identity.go + hybrid.go are pure-function, I/O-free derivation (BIP-39 -> BIP-32 -> ML-DSA-65 NodeID) that imports only luxfi/crypto + go-bip32/39 + ids — NO kms import, so no keys<->kms cycle. Previously these lived only vendored-in-kms; consumers (hanzoai/kms/sdk, bank, kms-operator) referenced keys.ServiceIdentity/NewServiceIdentity that the released keys lacked. This is the canonical home. (LoadMnemonic stays out of keys — it needs kms/zapclient.) Co-authored-by: Hanzo Dev <dev@hanzo.ai>
356 lines
14 KiB
Go
356 lines
14 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// hybrid.go — Bindel-Brendel-Fischlin (CCS 2021) + Cremers-Düzlü-Fiedler-
|
|
// Fischlin-Janson (Asiacrypt 2023) stronger-binding hybrid signature
|
|
// scheme for Lux validator identity.
|
|
//
|
|
// Construction (BBF21 N-Sig with CDFFJ23 joint-pk binding):
|
|
//
|
|
// m_bound = H_bind(pk_c, pk_pq, msg)
|
|
// = SHAKE256-384( "lux-hybrid-sig-v1"
|
|
// || left_encode(8·|pk_c|) || pk_c
|
|
// || left_encode(8·|pk_pq|) || pk_pq
|
|
// || left_encode(8·|msg|) || msg )
|
|
// sig_c = secp256k1.SignHash(sk_c, m_bound)
|
|
// sig_pq = mldsa65.SignCtx(sk_pq, m_bound, "lux-hybrid-sig-v1")
|
|
// Verify = AND( secp256k1.VerifyHash(pk_c, m_bound, sig_c),
|
|
// mldsa65.VerifyCtx(pk_pq, m_bound, sig_pq, "lux-hybrid-sig-v1") )
|
|
//
|
|
// Why this construction (vs raw concat):
|
|
//
|
|
// Raw concat `pk_c || pk_pq` and per-component signatures over `msg`
|
|
// reduces to MIN security under a non-honest-sign adversary
|
|
// (CDFFJ23 §4): an adversary that registers a malformed pk_pq can
|
|
// force the hybrid to drop to classical security only. By hashing
|
|
// BOTH pubkeys into m_bound, every signature is *cryptographically
|
|
// bound to the joint identity* — substituting either component
|
|
// invalidates the binding. This is BBF21 §3.2 Cons. 2 "Combined
|
|
// Concatenated" with the CDFFJ23 strengthening: m_bound includes
|
|
// both pubkeys so component swap is detectable.
|
|
//
|
|
// Why secp256k1 as classical (not ed25519 or X25519):
|
|
//
|
|
// (a) X25519 is a KEM, not a signature scheme — disqualified.
|
|
// (b) Lux P/X validator identity already anchors on secp256k1
|
|
// (see keys.ValidatorKey.ECPrivateKey + .PChainAddr). Picking
|
|
// secp256k1 means the hybrid is a pure superset — every existing
|
|
// validator's classical key continues to play the same role.
|
|
// (c) ed25519 would add a third primitive with no existing
|
|
// infrastructure: no NodeID derivation, no stake record format.
|
|
// secp256k1 is the only choice that preserves the one-and-only-one
|
|
// way of identifying a P/X validator.
|
|
//
|
|
// Security claim (BBF21 Thm. 1 + CDFFJ23 Thm. 3 corollary):
|
|
//
|
|
// The scheme is EUF-CMA under
|
|
// max( EUF-CMA_secp256k1, sEUF-CMA_MLDSA65 )
|
|
// in the chosen-message model where the adversary may submit
|
|
// adversarial pk_pq's during registration. This is strictly
|
|
// stronger than min(·, ·) which is all raw concatenation buys.
|
|
//
|
|
// Domain separation:
|
|
//
|
|
// The string "lux-hybrid-sig-v1" appears verbatim TWICE: once as
|
|
// the prefix of m_bound (binds the hash), once as the ML-DSA-65
|
|
// context (binds the PQ signature). This is intentional — a future
|
|
// v2 of the scheme (say, with ML-DSA-87 or different prefix bytes)
|
|
// would replace both occurrences atomically; no cross-version
|
|
// replay is possible.
|
|
|
|
package keys
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
|
|
mldsa "github.com/luxfi/crypto/mldsa"
|
|
secp "github.com/luxfi/crypto/secp256k1"
|
|
"golang.org/x/crypto/sha3"
|
|
)
|
|
|
|
// HybridSigDomain is the canonical domain-separation string for the
|
|
// Lux hybrid signature scheme. Pinned at v1; bumping invalidates every
|
|
// prior hybrid signature, which is the correct behaviour for a hardfork
|
|
// of the binding encoding. The same string is used both as the H_bind
|
|
// prefix and as the ML-DSA-65 context.
|
|
const HybridSigDomain = "lux-hybrid-sig-v1"
|
|
|
|
// HybridBoundDigestLen is the byte length of the m_bound digest the
|
|
// scheme uses internally. 48 bytes = SHAKE256-384, the same hash size
|
|
// as ids.FullDigest — matches the Lux convention for identity digests.
|
|
const HybridBoundDigestLen = 48
|
|
|
|
// Typed errors. errors.Is friendly.
|
|
var (
|
|
// ErrHybridNilKey — either component of the hybrid key is nil.
|
|
// Refused early; we never want a half-hybrid signing path.
|
|
ErrHybridNilKey = errors.New("keys: hybrid key has nil component")
|
|
|
|
// ErrHybridClassicalSign — secp256k1 signing failed. The error
|
|
// wraps the underlying secp256k1 error.
|
|
ErrHybridClassicalSign = errors.New("keys: hybrid classical sign failed")
|
|
|
|
// ErrHybridPQSign — ML-DSA-65 signing failed. The error wraps
|
|
// the underlying mldsa error.
|
|
ErrHybridPQSign = errors.New("keys: hybrid PQ sign failed")
|
|
|
|
// ErrHybridClassicalVerify — secp256k1 verification failed.
|
|
// A valid hybrid signature requires BOTH components to verify.
|
|
ErrHybridClassicalVerify = errors.New("keys: hybrid classical verification failed")
|
|
|
|
// ErrHybridPQVerify — ML-DSA-65 verification failed. A valid
|
|
// hybrid signature requires BOTH components to verify.
|
|
ErrHybridPQVerify = errors.New("keys: hybrid PQ verification failed")
|
|
|
|
// ErrHybridNilSig — signature struct or one of its components is
|
|
// nil/empty. Refused before any expensive verification.
|
|
ErrHybridNilSig = errors.New("keys: hybrid signature has nil component")
|
|
)
|
|
|
|
// HybridPublicKey is the joint pubkey of the BBF-bound hybrid scheme.
|
|
// Both components are required for any verification; a half-hybrid is
|
|
// a programmer error.
|
|
type HybridPublicKey struct {
|
|
// Classical is the secp256k1 verification key. 33-byte compressed
|
|
// SEC1 encoding on the wire. This is the same primitive the
|
|
// existing Lux validator set uses for P/X identity.
|
|
Classical *secp.PublicKey
|
|
|
|
// PQ is the ML-DSA-65 verification key (FIPS 204). Bytes-on-the-
|
|
// wire form is the standard ML-DSA-65 public key encoding.
|
|
PQ *mldsa.PublicKey
|
|
}
|
|
|
|
// HybridPrivateKey is the joint signing key of the BBF-bound hybrid
|
|
// scheme. Owns both private components; the only legal use is internal
|
|
// Sign(). Call Wipe() when done.
|
|
type HybridPrivateKey struct {
|
|
// Classical is the secp256k1 signing key (32 bytes scalar).
|
|
Classical *secp.PrivateKey
|
|
|
|
// PQ is the ML-DSA-65 signing key (FIPS 204).
|
|
PQ *mldsa.PrivateKey
|
|
}
|
|
|
|
// HybridSignature is the joint signature of the BBF-bound hybrid
|
|
// scheme. BOTH components are required for verification. Neither is
|
|
// independently useful — that is the binding the scheme provides.
|
|
type HybridSignature struct {
|
|
// Classical is the secp256k1 recoverable signature (65 bytes).
|
|
Classical []byte
|
|
|
|
// PQ is the ML-DSA-65 signature (FIPS 204 standard size).
|
|
PQ []byte
|
|
}
|
|
|
|
// HybridPublic extracts the joint public key from the joint private
|
|
// key. Pure function: no allocation other than the returned struct.
|
|
func (sk *HybridPrivateKey) Public() *HybridPublicKey {
|
|
if sk == nil {
|
|
return nil
|
|
}
|
|
return &HybridPublicKey{
|
|
Classical: sk.Classical.PublicKey(),
|
|
PQ: sk.PQ.PublicKey,
|
|
}
|
|
}
|
|
|
|
// Wipe zeroes both private components in place. Idempotent. Safe to
|
|
// call from a defer on a nil receiver.
|
|
func (sk *HybridPrivateKey) Wipe() {
|
|
if sk == nil {
|
|
return
|
|
}
|
|
if sk.PQ != nil {
|
|
sk.PQ.Zeroize()
|
|
sk.PQ = nil
|
|
}
|
|
// secp256k1.PrivateKey holds key bytes in an unexported field;
|
|
// the canonical wipe is to drop the pointer reference and let the
|
|
// GC reclaim — luxfi/crypto/secp256k1 does not export a Zeroize
|
|
// (the scalar is consumed by SignHash internally and the bytes
|
|
// live in the heap). Dropping the reference is the best we can
|
|
// portably do without forking the upstream API.
|
|
sk.Classical = nil
|
|
}
|
|
|
|
// boundDigest computes m_bound — the canonical SHAKE256-384 commitment
|
|
// the scheme binds. This is the joint message every signature is
|
|
// computed over and every verifier reconstructs.
|
|
//
|
|
// Wire shape (SP 800-185 left_encode framed):
|
|
//
|
|
// left_encode(8·|domain|) || domain (= HybridSigDomain)
|
|
// left_encode(8·|pk_classical|) || pk_classical (33 bytes SEC1)
|
|
// left_encode(8·|pk_pq|) || pk_pq (ML-DSA-65 pubkey)
|
|
// left_encode(8·|msg|) || msg
|
|
//
|
|
// 48-byte SHAKE256-384 output. The encoding is unambiguous: an
|
|
// adversary cannot smuggle field bytes across boundaries because each
|
|
// field's length is prepended.
|
|
func boundDigest(pk *HybridPublicKey, msg []byte) ([]byte, error) {
|
|
if pk == nil || pk.Classical == nil || pk.PQ == nil {
|
|
return nil, ErrHybridNilKey
|
|
}
|
|
pkClassical := pk.Classical.CompressedBytes() // 33 bytes
|
|
pkPQ := pk.PQ.Bytes() // ML-DSA-65 pubkey bytes
|
|
|
|
h := sha3.NewShake256()
|
|
_, _ = h.Write(leftEncode(uint64(len(HybridSigDomain)) * 8))
|
|
_, _ = h.Write([]byte(HybridSigDomain))
|
|
_, _ = h.Write(leftEncode(uint64(len(pkClassical)) * 8))
|
|
_, _ = h.Write(pkClassical)
|
|
_, _ = h.Write(leftEncode(uint64(len(pkPQ)) * 8))
|
|
_, _ = h.Write(pkPQ)
|
|
_, _ = h.Write(leftEncode(uint64(len(msg)) * 8))
|
|
_, _ = h.Write(msg)
|
|
|
|
out := make([]byte, HybridBoundDigestLen)
|
|
_, _ = h.Read(out)
|
|
return out, nil
|
|
}
|
|
|
|
// HybridBoundDigest is the exported helper computing m_bound. Same
|
|
// algorithm as the internal boundDigest; exported so an out-of-band
|
|
// verifier (e.g. an on-chain precompile or a different language's
|
|
// implementation) can reconstruct the bound message verbatim.
|
|
//
|
|
// Pure function: no I/O, no randomness.
|
|
func HybridBoundDigest(pk *HybridPublicKey, msg []byte) ([]byte, error) {
|
|
return boundDigest(pk, msg)
|
|
}
|
|
|
|
// HybridSign produces a BBF-bound joint signature over msg under the
|
|
// given joint signing key. The classical signature uses secp256k1
|
|
// SignHash over m_bound; the PQ signature uses ML-DSA-65 SignCtx with
|
|
// HybridSigDomain as the context.
|
|
//
|
|
// Why two domain-separated bindings:
|
|
//
|
|
// (1) m_bound's prefix binds the SHAKE256 input to this scheme.
|
|
// (2) The ML-DSA context binds the FIPS 204 signature itself to
|
|
// this scheme — preventing cross-protocol replay of a future
|
|
// hybrid signature against any other ML-DSA-65 surface (KMS
|
|
// envelope, P-Chain block, etc.).
|
|
//
|
|
// rand is the randomness source for ML-DSA-65 hedged signing. If nil,
|
|
// crypto/rand is used. The secp256k1 signature uses RFC 6979
|
|
// deterministic-k — no randomness needed.
|
|
func HybridSign(sk *HybridPrivateKey, msg []byte, randSource io.Reader) (*HybridSignature, error) {
|
|
if sk == nil || sk.Classical == nil || sk.PQ == nil {
|
|
return nil, ErrHybridNilKey
|
|
}
|
|
if randSource == nil {
|
|
randSource = rand.Reader
|
|
}
|
|
|
|
pk := sk.Public()
|
|
mBound, err := boundDigest(pk, msg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Classical: secp256k1 SignHash(m_bound). SignHash expects a
|
|
// 32-byte hash, but m_bound is 48 bytes (SHAKE256-384). The
|
|
// canonical thing to do is sign the first 32 bytes of the digest
|
|
// — keep the full SHAKE256-384 output for the PQ side (which
|
|
// can absorb arbitrary length via SignCtx), and use the prefix
|
|
// for the classical side which requires a 32-byte hash.
|
|
//
|
|
// This is sound: the SHAKE256-384 output is uniform random under
|
|
// the random-oracle assumption, so its 32-byte prefix is also
|
|
// uniform random — exactly what secp256k1 SignHash expects.
|
|
sigC, err := sk.Classical.SignHash(mBound[:32])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrHybridClassicalSign, err)
|
|
}
|
|
|
|
// PQ: ML-DSA-65 SignCtx with full 48-byte m_bound + domain
|
|
// context. FIPS 204 hedged signing reads randomness from
|
|
// randSource. SignCtx absorbs the full digest.
|
|
sigPQ, err := sk.PQ.SignCtx(randSource, mBound, []byte(HybridSigDomain))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrHybridPQSign, err)
|
|
}
|
|
|
|
return &HybridSignature{
|
|
Classical: sigC,
|
|
PQ: sigPQ,
|
|
}, nil
|
|
}
|
|
|
|
// HybridVerify checks a BBF-bound joint signature. Returns nil on
|
|
// success; returns a typed error identifying which component failed
|
|
// (or ErrHybridNilKey/ErrHybridNilSig for input errors).
|
|
//
|
|
// BOTH components MUST verify. This is the AND-mode binding: a
|
|
// signature where only one component verifies is a forgery (or a
|
|
// substitution attempt) and MUST be refused.
|
|
//
|
|
// The verification is order-independent: classical-first matches the
|
|
// natural reading of the construction, but a verifier could equally
|
|
// check PQ first. Either way, BOTH must pass.
|
|
func HybridVerify(pk *HybridPublicKey, msg []byte, sig *HybridSignature) error {
|
|
if pk == nil || pk.Classical == nil || pk.PQ == nil {
|
|
return ErrHybridNilKey
|
|
}
|
|
if sig == nil || len(sig.Classical) == 0 || len(sig.PQ) == 0 {
|
|
return ErrHybridNilSig
|
|
}
|
|
|
|
mBound, err := boundDigest(pk, msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Classical: secp256k1 VerifyHash over m_bound[:32]. Mirrors the
|
|
// signing path's 32-byte prefix choice.
|
|
if !pk.Classical.VerifyHash(mBound[:32], sig.Classical) {
|
|
return ErrHybridClassicalVerify
|
|
}
|
|
|
|
// PQ: ML-DSA-65 VerifySignatureCtx over full m_bound + domain
|
|
// context. Mirrors the signing-side SignCtx call.
|
|
if !pk.PQ.VerifySignatureCtx(mBound, sig.PQ, []byte(HybridSigDomain)) {
|
|
return ErrHybridPQVerify
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// HybridPublicKeyBytes returns the canonical wire encoding of the
|
|
// joint public key:
|
|
//
|
|
// left_encode(8·|pk_classical|) || pk_classical
|
|
// left_encode(8·|pk_pq|) || pk_pq
|
|
//
|
|
// This is the same framing m_bound uses for the joint pubkey, minus
|
|
// the domain prefix and msg. The encoding is unambiguous and can be
|
|
// reversed (each field's length is recoverable from its left_encode
|
|
// header).
|
|
//
|
|
// Used to compute a hybrid NodeID via the existing ids.NodeIDScheme
|
|
// derivation — the scheme produces NodeID = SHAKE256-384("NODE_ID_V1"
|
|
// || chainID || scheme_byte || pubkey)[:20], so passing the wire-form
|
|
// hybrid pubkey here yields a NodeID committed to BOTH components.
|
|
func HybridPublicKeyBytes(pk *HybridPublicKey) ([]byte, error) {
|
|
if pk == nil || pk.Classical == nil || pk.PQ == nil {
|
|
return nil, ErrHybridNilKey
|
|
}
|
|
pkClassical := pk.Classical.CompressedBytes()
|
|
pkPQ := pk.PQ.Bytes()
|
|
|
|
// Pre-size: header bytes for two left_encodes (<= 9 each) plus
|
|
// the two field payloads. The actual headers are typically 2-3
|
|
// bytes, but worst-case sizing keeps the allocation single.
|
|
out := make([]byte, 0, 18+len(pkClassical)+len(pkPQ))
|
|
out = append(out, leftEncode(uint64(len(pkClassical))*8)...)
|
|
out = append(out, pkClassical...)
|
|
out = append(out, leftEncode(uint64(len(pkPQ))*8)...)
|
|
out = append(out, pkPQ...)
|
|
return out, nil
|
|
}
|