mirror of
https://github.com/luxfi/crypto.git
synced 2026-10-30 16:59:13 +00:00
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]>
308 lines
10 KiB
Go
308 lines
10 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package entropy
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// fixed is a source that returns the same bytes forever and claims to be well.
|
|
// It stands for "an attacker knows exactly what this contributes".
|
|
type fixed struct {
|
|
name string
|
|
b byte
|
|
}
|
|
|
|
func (f fixed) Name() string { return f.name }
|
|
func (f fixed) Health() error { return nil }
|
|
func (f fixed) Read(p []byte) (int, error) {
|
|
for i := range p {
|
|
p[i] = f.b
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
// broken reports itself unhealthy.
|
|
type broken struct{ name string }
|
|
|
|
func (b broken) Name() string { return b.name }
|
|
func (b broken) Health() error { return ErrUnhealthy }
|
|
func (b broken) Read(p []byte) (int, error) { return 0, ErrUnhealthy }
|
|
|
|
func draw(t *testing.T, s Source, n int) []byte {
|
|
t.Helper()
|
|
b := make([]byte, n)
|
|
if _, err := io.ReadFull(s, b); err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// TestOneGoodSourceCarriesTheCombination is the property the whole design
|
|
// rests on: a source that contributes nothing cannot take anything away. It is
|
|
// what makes it safe to put an unproven device into a running deployment.
|
|
func TestOneGoodSourceCarriesTheCombination(t *testing.T) {
|
|
// Every source but one is a constant the adversary knows.
|
|
withKnown := Combine(fixed{"known-a", 0x00}, OS(), fixed{"known-b", 0xFF})
|
|
alone := Combine(OS())
|
|
|
|
seen := map[string]bool{}
|
|
for i := 0; i < 64; i++ {
|
|
for _, s := range []Source{withKnown, alone} {
|
|
b := string(draw(t, s, 32))
|
|
if seen[b] {
|
|
t.Fatal("a draw repeated: the known sources are determining the output")
|
|
}
|
|
seen[b] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// And the converse: with no unpredictable source, the output is a function of
|
|
// what the adversary knows. This is the control — without it the test above
|
|
// would pass for a combiner that ignored its inputs entirely.
|
|
func TestKnownSourcesAloneGiveAPredictableResult(t *testing.T) {
|
|
a := Combine(fixed{"a", 0x01}, fixed{"b", 0x02})
|
|
b := Combine(fixed{"a", 0x01}, fixed{"b", 0x02})
|
|
if !bytes.Equal(draw(t, a, 32), draw(t, b, 32)) {
|
|
t.Fatal("two combinations of the same known sources disagreed")
|
|
}
|
|
}
|
|
|
|
// TestLengthsKeepContributionsApart is tested against mix directly, because
|
|
// the ambiguity it prevents needs contributions of differing length and
|
|
// Combine draws a fixed amount from every source. "ab" then "c" and "a" then
|
|
// "bc" concatenate to the same bytes; without a length prefix they hash alike,
|
|
// and two deployments holding different things would derive one value.
|
|
func TestLengthsKeepContributionsApart(t *testing.T) {
|
|
read := func(parts ...[]byte) []byte {
|
|
out := make([]byte, 32)
|
|
if _, err := io.ReadFull(mix(parts), out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
cases := [][2][][]byte{
|
|
{{[]byte("ab"), []byte("c")}, {[]byte("a"), []byte("bc")}},
|
|
{{[]byte("abc")}, {[]byte("ab"), []byte("c")}},
|
|
{{[]byte(""), []byte("x")}, {[]byte("x"), []byte("")}},
|
|
}
|
|
for _, c := range cases {
|
|
if bytes.Equal(read(c[0]...), read(c[1]...)) {
|
|
t.Fatalf("%q and %q mixed to one value", c[0], c[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
// The same contributions must of course still give the same value, or the
|
|
// construction is not a function.
|
|
func TestMixIsAFunctionOfItsContributions(t *testing.T) {
|
|
read := func() []byte {
|
|
out := make([]byte, 32)
|
|
if _, err := io.ReadFull(mix([][]byte{[]byte("ab"), []byte("c")}), out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
if !bytes.Equal(read(), read()) {
|
|
t.Fatal("the same contributions mixed to two different values")
|
|
}
|
|
}
|
|
|
|
// A combination is only as usable as its least healthy member: a deployment
|
|
// told a source is broken should stop, not quietly continue on the rest while
|
|
// believing it has more sources than it has.
|
|
func TestAnUnhealthySourceFailsTheCombination(t *testing.T) {
|
|
c := Combine(OS(), broken{"dead"})
|
|
if err := c.Health(); !errors.Is(err, ErrUnhealthy) {
|
|
t.Fatalf("health = %v, want unhealthy", err)
|
|
}
|
|
if _, err := c.Read(make([]byte, 32)); !errors.Is(err, ErrUnhealthy) {
|
|
t.Fatalf("read from an unhealthy combination returned %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNoSourcesIsNotASource(t *testing.T) {
|
|
if err := Combine().Health(); !errors.Is(err, ErrUnhealthy) {
|
|
t.Fatal("a combination of nothing reported healthy")
|
|
}
|
|
}
|
|
|
|
// TestRepetitionCountCatchesAStuckSource. The loudest failure a physical
|
|
// source has: it stops changing. The test must catch it within a few samples,
|
|
// because everything downstream will happily use the output.
|
|
func TestRepetitionCountCatchesAStuckSource(t *testing.T) {
|
|
// 7.9 bits per byte is a near-ideal source; its cutoff is small.
|
|
tst := newRepetitionCount(7.9)
|
|
var err error
|
|
for i := 0; i < 100 && err == nil; i++ {
|
|
err = tst.Observe(0x42)
|
|
}
|
|
if !errors.Is(err, ErrUnhealthy) {
|
|
t.Fatalf("a source stuck at one value was not caught: %v", err)
|
|
}
|
|
|
|
// A source that changes is left alone.
|
|
tst.Reset()
|
|
for i := 0; i < 10000; i++ {
|
|
if err := tst.Observe(byte(i)); err != nil {
|
|
t.Fatalf("a healthy source was failed at sample %d: %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A failed test stays failed. A source that has stopped being random is not
|
|
// re-trusted by being asked again.
|
|
func TestAFailedTestStaysFailed(t *testing.T) {
|
|
tst := newRepetitionCount(7.9)
|
|
for i := 0; i < 100; i++ {
|
|
_ = tst.Observe(0x42)
|
|
}
|
|
if err := tst.Observe(0x01); !errors.Is(err, ErrUnhealthy) {
|
|
t.Fatal("a failed test recovered on a different sample")
|
|
}
|
|
}
|
|
|
|
// TestAdaptiveProportionCatchesACollapsedSource. The quieter failure: the
|
|
// source still changes value, but draws from far fewer values than it claims.
|
|
// A repetition-count test never sees this.
|
|
func TestAdaptiveProportionCatchesACollapsedSource(t *testing.T) {
|
|
tst := newAdaptiveProportion(7.9, adaptiveWindowOther)
|
|
var err error
|
|
// Alternate between two values: never repeats, but carries one bit.
|
|
for i := 0; i < 4096 && err == nil; i++ {
|
|
err = tst.Observe(byte(i % 2))
|
|
}
|
|
if !errors.Is(err, ErrUnhealthy) {
|
|
t.Fatalf("a source alternating between two values was not caught: %v", err)
|
|
}
|
|
}
|
|
|
|
// The cutoffs must admit a healthy source. A test that fails everything proves
|
|
// nothing, and would take a working deployment down.
|
|
func TestHealthyRandomnessIsNotFailed(t *testing.T) {
|
|
m := newMonitor(7.9, false)
|
|
buf := make([]byte, 1<<16)
|
|
if _, err := io.ReadFull(OS(), buf); err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if err := m.observe(buf); err != nil {
|
|
t.Fatalf("real randomness was declared unhealthy: %v", err)
|
|
}
|
|
if err := m.health(); err != nil {
|
|
t.Fatalf("health after 65536 good samples: %v", err)
|
|
}
|
|
}
|
|
|
|
// A source releases nothing until it has passed a run of samples, so one that
|
|
// comes up broken is refused rather than used once and then noticed.
|
|
func TestNothingIsReleasedBeforeStartupCompletes(t *testing.T) {
|
|
m := newMonitor(7.9, false)
|
|
if err := m.health(); !errors.Is(err, ErrUnhealthy) {
|
|
t.Fatal("a source reported healthy before startup completed")
|
|
}
|
|
buf := make([]byte, startupSamples)
|
|
if _, err := io.ReadFull(OS(), buf); err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if err := m.observe(buf); err != nil {
|
|
t.Fatalf("startup: %v", err)
|
|
}
|
|
if err := m.health(); err != nil {
|
|
t.Fatalf("health after startup: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCutoffsMatchTheClaimedEntropy. The cutoff is what ties a test to the
|
|
// assessment behind a device: a source claiming less per sample must be
|
|
// allowed longer runs before it is called broken, or every honest low-rate
|
|
// device is refused.
|
|
func TestCutoffsMatchTheClaimedEntropy(t *testing.T) {
|
|
strong := newRepetitionCount(8.0).cutoff
|
|
weak := newRepetitionCount(1.0).cutoff
|
|
if weak <= strong {
|
|
t.Fatalf("a source claiming 1 bit/sample got cutoff %d, one claiming 8 got %d: "+
|
|
"a weaker claim must permit longer runs", weak, strong)
|
|
}
|
|
// The stated form: C = 1 + ceil(40 / h).
|
|
for _, h := range []float64{1, 2, 4, 7.9, 8} {
|
|
want := 1 + int(math.Ceil(40.0/h))
|
|
if got := newRepetitionCount(h).cutoff; got != want {
|
|
t.Errorf("h=%v: cutoff %d, want %d", h, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The adaptive cutoff must sit above what a healthy source reaches and below
|
|
// what a collapsed one does, or it is either useless or hostile.
|
|
func TestAdaptiveCutoffIsBetweenHealthyAndCollapsed(t *testing.T) {
|
|
c := binomialCutoff(adaptiveWindowOther, math.Exp2(-7.9))
|
|
if c <= 1 {
|
|
t.Fatalf("cutoff %d would fail any repeated value at all", c)
|
|
}
|
|
if c >= adaptiveWindowOther {
|
|
t.Fatalf("cutoff %d cannot be reached inside the window", c)
|
|
}
|
|
// A fair byte source repeats a given value ~2 times in 512; a collapsed
|
|
// one repeats it ~256 times. The cutoff belongs between.
|
|
if c > adaptiveWindowOther/4 {
|
|
t.Fatalf("cutoff %d is too loose to catch a collapse", c)
|
|
}
|
|
}
|
|
|
|
// TestDeviceRefusesAStuckDevice: a file of one repeated byte stands for a
|
|
// source that has stuck. It must be refused at open rather than accepted and
|
|
// used.
|
|
func TestDeviceRefusesAStuckDevice(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "stuck")
|
|
if err := os.WriteFile(path, bytes.Repeat([]byte{0x5A}, 1<<16), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Device(path, 7.9); err == nil {
|
|
t.Fatal("a device stuck at one value was accepted")
|
|
}
|
|
}
|
|
|
|
// And a device that behaves is opened, conditioned and read.
|
|
func TestDeviceReadsAndConditions(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "good")
|
|
buf := make([]byte, 1<<20)
|
|
if _, err := io.ReadFull(OS(), buf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, buf, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d, err := Device(path, 7.9)
|
|
if err != nil {
|
|
t.Fatalf("open a healthy device: %v", err)
|
|
}
|
|
if err := d.Health(); err != nil {
|
|
t.Fatalf("health: %v", err)
|
|
}
|
|
out := draw(t, d, 32)
|
|
// Whatever else it is, it must not be the raw file.
|
|
if bytes.Contains(buf[:4096], out) {
|
|
t.Fatal("device output appears verbatim in the raw samples: it was not conditioned")
|
|
}
|
|
}
|
|
|
|
func TestDeviceRefusesAnImpossibleEntropyClaim(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "d")
|
|
if err := os.WriteFile(path, make([]byte, 16), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, h := range []float64{0, -1, 8.1, 100} {
|
|
if _, err := Device(path, h); err == nil {
|
|
t.Fatalf("a claim of %v bits per byte was accepted", h)
|
|
}
|
|
}
|
|
}
|