From 84c1e8e1d74a7d1ddcd23754726ad97a9aefa905 Mon Sep 17 00:00:00 2001 From: Hanzo AI Date: Tue, 28 Apr 2026 02:26:15 -0700 Subject: [PATCH] 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. --- fhe.go | 162 ++++++++++++++++++++++++++++++++++---------- go.mod | 2 +- keygen_seed_test.go | 145 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+), 38 deletions(-) create mode 100644 keygen_seed_test.go diff --git a/fhe.go b/fhe.go index 323c606..47ac379 100644 --- a/fhe.go +++ b/fhe.go @@ -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, } } diff --git a/go.mod b/go.mod index 5f3105c..d1c595d 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/keygen_seed_test.go b/keygen_seed_test.go new file mode 100644 index 0000000..191da93 --- /dev/null +++ b/keygen_seed_test.go @@ -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") + } +}