Files
zeekayandHanzo Dev 86ce8b49d5 entropy: the source LP-187 specifies
Every secret here is a draw — ML-DSA and ML-KEM keys, TLS material, signing
nonces, threshold dealer seeds — and each is exactly as unpredictable as the
draw that made it. This is the layer that produces those draws and says whether
they are worth anything.

A source is bytes plus a verdict on them, and the verdict is the part usually
left out. A physical source that has drifted, saturated or stuck still emits
bytes of the expected length; a key made from them verifies, signs and behaves
correctly in every respect except being unpredictable, and nothing downstream
can tell. So Read fails closed, and the two continuous tests of SP 800-90B §4.4
run over every sample: repetition count for a source that has stopped changing,
adaptive proportion for one still changing but drawing from far fewer values
than it claims. Cutoffs come from the device's own min-entropy claim, so a
weaker claim is allowed longer runs rather than being refused for being honest.
Nothing is released until a startup run has passed.

Sources combine so that adding one cannot weaken the result: predicting the
output means predicting every contribution, so one unpredictable source carries
the whole combination. That is what makes it safe to put an unproven device
into a running deployment — at worst it contributes nothing. The platform
generator is always among the sources, because a deployment whose only source
is a device it cannot audit has moved its trust rather than removed it.

Mixing is separated from reading so the property it exists for can be tested
directly. It needs to be: with a fixed draw per source the contributions are
always the same length, so a test of injectivity through Combine passes with or
without the length prefixes and proves nothing. Against mix, ["ab","c"] and
["a","bc"] collide the moment the prefixes are removed.

Lives beside mldsa and mlkem rather than in a module of its own. LP-176 names
luxfi/qrng, but a separate module means a separate go.mod, CI, and release
cadence for one package whose only callers are its neighbours here.

Integration is the claim LP-187 makes: mldsa.GenerateKey(src, …) — adopting a
hardware source is supplying a different reader. No consensus change and no
fork. A key is generated, signed with and verified in the tests, and refused
outright when a source is unhealthy.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-26 15:46:19 -07:00

108 lines
3.7 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package entropy
import (
"encoding/binary"
"fmt"
"io"
"golang.org/x/crypto/sha3"
)
// combineTag separates this use of SHAKE256 from every other one in Lux. Two
// constructions that hash the same bytes for different purposes must not be
// able to produce the same output, or a value drawn for one becomes usable in
// the other.
const combineTag = "LUX-ENTROPY-COMBINE-V1"
// drawPerSource is how many bytes are taken from each source per read. It is
// the security parameter's worth: a source contributing at least this much
// unpredictability saturates the output.
const drawPerSource = 32
// Combine reads from every source and mixes what it gets.
//
// The mix is a hash of the concatenation, each contribution preceded by its
// length, under a tag used nowhere else:
//
// SHAKE256( tag || LE64(len(s_1)) || s_1 || ... || LE64(len(s_n)) || s_n )
//
// Two properties follow, and both are the point rather than decoration.
//
// Adding a source cannot weaken the result. Predicting the output means
// predicting the hash input, which means predicting every contribution — so
// one unpredictable source is enough regardless of what the others do. This is
// what allows a new and unproven device to be added to a running deployment:
// at worst it contributes nothing, and it can never subtract.
//
// The lengths keep the contributions distinct. Concatenated raw, different
// source sets can produce identical inputs — the last bytes of one and the
// first of the next are indistinguishable from a single longer contribution —
// and two deployments would silently derive the same value. Length-prefixing
// makes the encoding injective, so the input determines the contributions.
//
// A source that is unhealthy fails the whole read. That is deliberate: a
// deployment that has been told a source is broken should stop and be fixed,
// not continue on the remainder while believing it has more sources than it
// does.
func Combine(sources ...Source) Source {
return &combined{sources: sources}
}
type combined struct {
sources []Source
}
func (c *combined) Name() string { return "combined" }
func (c *combined) Health() error {
if len(c.sources) == 0 {
return fmt.Errorf("%w: no sources", ErrUnhealthy)
}
for i, s := range c.sources {
if err := s.Health(); err != nil {
return fmt.Errorf("%s: %w", nameOf(s, i), err)
}
}
return nil
}
func (c *combined) Read(p []byte) (int, error) {
if err := c.Health(); err != nil {
return 0, err
}
parts := make([][]byte, len(c.sources))
for i, s := range c.sources {
buf := make([]byte, drawPerSource)
if _, err := io.ReadFull(s, buf); err != nil {
return 0, fmt.Errorf("entropy: read %s: %w", nameOf(s, i), err)
}
parts[i] = buf
}
return io.ReadFull(mix(parts), p)
}
// mix is the combining construction itself, separated from reading so that the
// property it exists for can be checked directly: distinct contribution lists
// must give distinct output.
//
// Each contribution is preceded by its length. Combine happens to draw a fixed
// number of bytes from every source, which makes the lengths uniform today —
// but a construction that is only injective while every input happens to be
// the same size is one bad day from not being injective at all. The prefix is
// what makes the encoding injective for any contributions, so this stays true
// if a source ever contributes a different amount.
func mix(parts [][]byte) io.Reader {
h := sha3.NewShake256()
h.Write([]byte(combineTag))
var length [8]byte
for _, part := range parts {
binary.LittleEndian.PutUint64(length[:], uint64(len(part)))
h.Write(length[:])
h.Write(part)
}
return h
}