feat(keygen): real deterministic NewKeyGeneratorFromSeed (v1.8.0)

Replace the stub implementation with a real HKDF-SHA256 derivation that
seeds blake2b KeyedPRNG streams driving ring.NewSampler over the secret
distribution Xs. Same seed -> byte-identical secret keys, the consensus
invariant validators rely on.

Pipeline:
  prk    = HKDF-Extract(SHA-256, salt="LUX_FHE_KEYGEN_v1", ikm=seed)
  keyLWE = HKDF-Expand(prk, "LUX_FHE_KEYGEN_v1:LWE", 32)
  keyBR  = HKDF-Expand(prk, "LUX_FHE_KEYGEN_v1:BR",  32)

Tests:
  - Deterministic (same seed -> same SK bytes)
  - DifferentSeeds (distinct seeds -> distinct SKs)
  - GoldenVector (sha256(SKBR) pinned for PN10QP27)
  - EmptySeed (rejected)

Unblocks luxfi/precompile fhe/fhe_ops.go:49 which calls this symbol.
This commit is contained in:
Hanzo AI
2026-04-28 02:26:15 -07:00
parent 5797cae572
commit 84c1e8e1d7
3 changed files with 271 additions and 38 deletions
+125 -37
View File
@@ -14,13 +14,16 @@
package fhe
import (
"crypto/sha256"
"fmt"
"io"
"github.com/luxfi/lattice/v7/core/rgsw/blindrot"
"github.com/luxfi/lattice/v7/core/rlwe"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils"
"github.com/luxfi/lattice/v7/utils/sampling"
"golang.org/x/crypto/hkdf"
)
// Parameters defines the FHE parameter set
@@ -203,6 +206,11 @@ type KeyGenerator struct {
kgenBR *rlwe.KeyGenerator
ringQBR *ring.Ring
scaleBR float64
// prngLWE and prngBR are non-nil only when the generator was constructed
// via NewKeyGeneratorFromSeed. They drive deterministic sampling of the
// secret-key coefficients in GenSecretKey.
prngLWE sampling.PRNG
prngBR sampling.PRNG
}
// NewKeyGenerator creates a new key generator
@@ -216,71 +224,151 @@ func NewKeyGenerator(params Parameters) *KeyGenerator {
}
}
// NewKeyGeneratorFromSeed creates a key generator using a deterministic PRNG
// seeded with the given key. All validators using the same seed will produce
// identical FHE keys, which is required for consensus.
// keygenHKDFInfoLWE is the HKDF info string for the LWE secret-key stream.
// Domain-separated from BR to ensure the two PRNG streams never collide.
const keygenHKDFInfoLWE = "LUX_FHE_KEYGEN_v1:LWE"
// keygenHKDFInfoBR is the HKDF info string for the blind-rotation secret-key
// stream. Domain-separated from LWE.
const keygenHKDFInfoBR = "LUX_FHE_KEYGEN_v1:BR"
// keygenHKDFSalt is a fixed salt used for HKDF-SHA256 extract. Treated as a
// network constant — changing it invalidates all keys derived from prior seeds.
var keygenHKDFSalt = []byte("LUX_FHE_KEYGEN_v1")
// NewKeyGeneratorFromSeed creates a key generator that deterministically
// derives the secret-key material from `seed`. All validators using the same
// seed produce identical secret keys (and therefore identical public/bootstrap
// keys), which is required for consensus.
//
// Derivation pipeline:
//
// prk = HKDF-Extract(SHA-256, salt=keygenHKDFSalt, ikm=seed)
// keyLWE = HKDF-Expand(prk, info="LUX_FHE_KEYGEN_v1:LWE", L=32)
// keyBR = HKDF-Expand(prk, info="LUX_FHE_KEYGEN_v1:BR", L=32)
//
// keyLWE and keyBR seed two independent blake2b-based KeyedPRNG streams which
// drive `ring.NewSampler` to fill the secret-key polynomial coefficients
// according to the parameter set's secret distribution `Xs`.
//
// WARNING: The seed is a network parameter. Changing it invalidates all
// existing ciphertexts. Use a domain-separated constant (e.g. "LUX_FHE_KEYGEN_v1").
// existing ciphertexts. Use a domain-separated constant
// (e.g. "LUX_FHE_KEYGEN_v1").
func NewKeyGeneratorFromSeed(params Parameters, seed []byte) (*KeyGenerator, error) {
prng, err := sampling.NewKeyedPRNG(seed)
if err != nil {
return nil, fmt.Errorf("fhe: NewKeyedPRNG: %w", err)
if len(seed) == 0 {
return nil, fmt.Errorf("fhe: NewKeyGeneratorFromSeed: empty seed")
}
kgenLWE, err := newRLWEKeyGeneratorFromPRNG(params.paramsLWE, prng)
// HKDF-SHA256 extract once, expand to two domain-separated 32-byte keys.
prk := hkdf.Extract(sha256.New, seed, keygenHKDFSalt)
keyLWE, err := hkdfExpand32(prk, keygenHKDFInfoLWE)
if err != nil {
return nil, fmt.Errorf("fhe: LWE keygen: %w", err)
return nil, fmt.Errorf("fhe: HKDF-Expand LWE: %w", err)
}
// For the blind rotation key generator, create a second keyed PRNG
// derived from the same seed but domain-separated so the two streams
// never collide.
brSeed := append(seed, []byte(":BR")...)
prngBR, err := sampling.NewKeyedPRNG(brSeed)
keyBR, err := hkdfExpand32(prk, keygenHKDFInfoBR)
if err != nil {
return nil, fmt.Errorf("fhe: HKDF-Expand BR: %w", err)
}
prngLWE, err := sampling.NewKeyedPRNG(keyLWE)
if err != nil {
return nil, fmt.Errorf("fhe: NewKeyedPRNG LWE: %w", err)
}
prngBR, err := sampling.NewKeyedPRNG(keyBR)
if err != nil {
return nil, fmt.Errorf("fhe: NewKeyedPRNG BR: %w", err)
}
kgenBR, err := newRLWEKeyGeneratorFromPRNG(params.paramsBR, prngBR)
if err != nil {
return nil, fmt.Errorf("fhe: BR keygen: %w", err)
}
return &KeyGenerator{
params: params,
kgenLWE: kgenLWE,
kgenBR: kgenBR,
kgenLWE: rlwe.NewKeyGenerator(params.paramsLWE),
kgenBR: rlwe.NewKeyGenerator(params.paramsBR),
ringQBR: params.paramsBR.RingQ(),
scaleBR: float64(params.QBR()) / 8.0,
// Stash the per-stream PRNGs; GenSecretKey consumes them when set so
// the resulting secret key is fully deterministic for a given seed.
prngLWE: prngLWE,
prngBR: prngBR,
}, nil
}
// newRLWEKeyGeneratorFromPRNG creates an rlwe.KeyGenerator.
//
// Note: lattice/v7 does not expose a public FromPRNG constructor.
// We create a standard generator; deterministic seeding happens at a
// higher level via the evaluation key derivation pipeline. The PRNG
// parameter is accepted for API compatibility but currently unused.
//
// TODO(luxfi/lattice#42): upstream a KeyGenerator.WithPRNG option so
// consensus validators can derive identical keys from the same seed.
func newRLWEKeyGeneratorFromPRNG(params rlwe.Parameters, _ *sampling.KeyedPRNG) (*rlwe.KeyGenerator, error) {
kg := rlwe.NewKeyGenerator(params)
return kg, nil
// hkdfExpand32 expands `prk` to a 32-byte key using HKDF-SHA256 with the
// given info string.
func hkdfExpand32(prk []byte, info string) ([]byte, error) {
r := hkdf.Expand(sha256.New, prk, []byte(info))
out := make([]byte, 32)
if _, err := io.ReadFull(r, out); err != nil {
return nil, err
}
return out, nil
}
// GenSecretKey generates a new secret key pair
// sampleSecretKeyDeterministic fills `sk` in-place with secret-key
// coefficients drawn from the parameter set's `Xs` distribution using `prng`
// as the source of randomness. The polynomial is left in NTT + Montgomery
// form, matching the convention used by `rlwe.KeyGenerator.GenSecretKeyNew`.
func sampleSecretKeyDeterministic(params rlwe.Parameters, prng sampling.PRNG, sk *rlwe.SecretKey) error {
ringQP := params.RingQP()
// RingQ is always present; sample Xs into sk.Value.Q at level Q.
samplerQ, err := ring.NewSampler(prng, ringQP.RingQ, params.Xs(), false)
if err != nil {
return fmt.Errorf("fhe: ring.NewSampler Q: %w", err)
}
samplerQ.AtLevel(sk.LevelQ()).Read(sk.Value.Q)
ringQP.RingQ.AtLevel(sk.LevelQ()).NTT(sk.Value.Q, sk.Value.Q)
ringQP.RingQ.AtLevel(sk.LevelQ()).MForm(sk.Value.Q, sk.Value.Q)
// RingP is optional (only when special primes P are configured).
if ringQP.RingP != nil && sk.LevelP() >= 0 {
samplerP, err := ring.NewSampler(prng, ringQP.RingP, params.Xs(), false)
if err != nil {
return fmt.Errorf("fhe: ring.NewSampler P: %w", err)
}
samplerP.AtLevel(sk.LevelP()).Read(sk.Value.P)
ringQP.RingP.AtLevel(sk.LevelP()).NTT(sk.Value.P, sk.Value.P)
ringQP.RingP.AtLevel(sk.LevelP()).MForm(sk.Value.P, sk.Value.P)
}
return nil
}
// GenSecretKey generates a new secret key pair.
//
// When the generator was constructed via NewKeyGeneratorFromSeed the secret
// key coefficients are sampled from the seeded blake2b stream so the result
// is fully deterministic. Otherwise the standard cryptographically random
// sampler is used.
func (kg *KeyGenerator) GenSecretKey() *SecretKey {
// When LWE and BR have the same dimension, use the same key for both
// This simplifies bootstrapping by eliminating key switching
// This simplifies bootstrapping by eliminating key switching.
if kg.params.N() == kg.params.NBR() {
sk := kg.kgenBR.GenSecretKeyNew()
if kg.prngBR != nil {
if err := sampleSecretKeyDeterministic(kg.params.paramsBR, kg.prngBR, sk); err != nil {
panic(fmt.Sprintf("fhe: deterministic BR sample: %v", err))
}
}
return &SecretKey{
SKLWE: sk,
SKBR: sk,
}
}
// Different dimensions require separate keys
// Different dimensions require separate keys.
skLWE := kg.kgenLWE.GenSecretKeyNew()
skBR := kg.kgenBR.GenSecretKeyNew()
if kg.prngLWE != nil {
if err := sampleSecretKeyDeterministic(kg.params.paramsLWE, kg.prngLWE, skLWE); err != nil {
panic(fmt.Sprintf("fhe: deterministic LWE sample: %v", err))
}
}
if kg.prngBR != nil {
if err := sampleSecretKeyDeterministic(kg.params.paramsBR, kg.prngBR, skBR); err != nil {
panic(fmt.Sprintf("fhe: deterministic BR sample: %v", err))
}
}
return &SecretKey{
SKLWE: kg.kgenLWE.GenSecretKeyNew(),
SKBR: kg.kgenBR.GenSecretKeyNew(),
SKLWE: skLWE,
SKBR: skBR,
}
}
+1 -1
View File
@@ -11,6 +11,7 @@ require (
github.com/luxfi/metric v1.5.0
github.com/spf13/cobra v1.10.2
github.com/urfave/cli/v3 v3.6.2
golang.org/x/crypto v0.49.0
)
require (
@@ -78,7 +79,6 @@ require (
go.temporal.io/api v1.62.6 // indirect
go.temporal.io/sdk v1.41.1 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/image v0.38.0 // indirect
golang.org/x/mod v0.34.0 // indirect
+145
View File
@@ -0,0 +1,145 @@
// Copyright (c) 2025, Lux Industries Inc
// SPDX-License-Identifier: BSD-3-Clause
package fhe
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"testing"
)
// TestNewKeyGeneratorFromSeed_Deterministic verifies that the same seed
// produces byte-identical secret keys across independent invocations.
// This is the consensus invariant: every validator must derive the same
// key from the same network seed.
func TestNewKeyGeneratorFromSeed_Deterministic(t *testing.T) {
params, err := NewParametersFromLiteral(PN10QP27)
if err != nil {
t.Fatalf("NewParametersFromLiteral: %v", err)
}
seed := []byte("LUX_FHE_KEYGEN_v1:test-seed-1")
kg1, err := NewKeyGeneratorFromSeed(params, seed)
if err != nil {
t.Fatalf("NewKeyGeneratorFromSeed #1: %v", err)
}
kg2, err := NewKeyGeneratorFromSeed(params, seed)
if err != nil {
t.Fatalf("NewKeyGeneratorFromSeed #2: %v", err)
}
sk1 := kg1.GenSecretKey()
sk2 := kg2.GenSecretKey()
b1, err := sk1.SKBR.MarshalBinary()
if err != nil {
t.Fatalf("sk1 marshal: %v", err)
}
b2, err := sk2.SKBR.MarshalBinary()
if err != nil {
t.Fatalf("sk2 marshal: %v", err)
}
if !bytes.Equal(b1, b2) {
t.Fatalf("same seed produced different SKBR: len1=%d len2=%d", len(b1), len(b2))
}
if params.N() != params.NBR() {
bL1, err := sk1.SKLWE.MarshalBinary()
if err != nil {
t.Fatalf("sk1 LWE marshal: %v", err)
}
bL2, err := sk2.SKLWE.MarshalBinary()
if err != nil {
t.Fatalf("sk2 LWE marshal: %v", err)
}
if !bytes.Equal(bL1, bL2) {
t.Fatalf("same seed produced different SKLWE")
}
}
}
// TestNewKeyGeneratorFromSeed_DifferentSeeds verifies distinct seeds yield
// distinct secret keys. This catches accidental constant-output bugs.
func TestNewKeyGeneratorFromSeed_DifferentSeeds(t *testing.T) {
params, err := NewParametersFromLiteral(PN10QP27)
if err != nil {
t.Fatalf("NewParametersFromLiteral: %v", err)
}
kgA, err := NewKeyGeneratorFromSeed(params, []byte("seed-A"))
if err != nil {
t.Fatalf("kgA: %v", err)
}
kgB, err := NewKeyGeneratorFromSeed(params, []byte("seed-B"))
if err != nil {
t.Fatalf("kgB: %v", err)
}
skA := kgA.GenSecretKey()
skB := kgB.GenSecretKey()
bA, _ := skA.SKBR.MarshalBinary()
bB, _ := skB.SKBR.MarshalBinary()
if bytes.Equal(bA, bB) {
t.Fatalf("distinct seeds produced identical secret key (degenerate sampler)")
}
}
// TestNewKeyGeneratorFromSeed_GoldenVector pins the SHA-256 of the marshalled
// secret key for a known seed under PN10QP27. Any change to the derivation
// pipeline (HKDF salt, info strings, sampler order, NTT/Montgomery form,
// underlying lattice library) will break this vector and must be reviewed
// as a network-breaking change.
func TestNewKeyGeneratorFromSeed_GoldenVector(t *testing.T) {
params, err := NewParametersFromLiteral(PN10QP27)
if err != nil {
t.Fatalf("NewParametersFromLiteral: %v", err)
}
// Fixed network seed used for the golden vector.
seed := []byte("LUX_FHE_KEYGEN_v1:golden:0001")
kg, err := NewKeyGeneratorFromSeed(params, seed)
if err != nil {
t.Fatalf("NewKeyGeneratorFromSeed: %v", err)
}
sk := kg.GenSecretKey()
raw, err := sk.SKBR.MarshalBinary()
if err != nil {
t.Fatalf("MarshalBinary: %v", err)
}
digest := sha256.Sum256(raw)
got := hex.EncodeToString(digest[:])
// Cross-process golden vector. Updating this requires explicit network
// review: any change here invalidates all keys derived under prior versions.
const goldenDigest = "75becea8a116b6f04469ba39bbcfe9ecbbe893c3fbedcc50f30c653a2004ec73"
if got != goldenDigest {
t.Fatalf("golden vector drift:\n want %s\n got %s", goldenDigest, got)
}
t.Logf("PN10QP27 SKBR sha256(seed=%q) = %s", seed, got)
// Determinism check — same seed, fresh generator, same digest.
kg2, _ := NewKeyGeneratorFromSeed(params, seed)
sk2 := kg2.GenSecretKey()
raw2, _ := sk2.SKBR.MarshalBinary()
digest2 := sha256.Sum256(raw2)
if !bytes.Equal(digest[:], digest2[:]) {
t.Fatalf("re-derivation differs:\n first %x\n second %x", digest, digest2)
}
}
// TestNewKeyGeneratorFromSeed_EmptySeed asserts the empty-seed guard.
func TestNewKeyGeneratorFromSeed_EmptySeed(t *testing.T) {
params, err := NewParametersFromLiteral(PN10QP27)
if err != nil {
t.Fatalf("NewParametersFromLiteral: %v", err)
}
if _, err := NewKeyGeneratorFromSeed(params, nil); err == nil {
t.Fatalf("expected error for empty seed")
}
}