mirror of
https://github.com/luxfi/fhe.git
synced 2026-07-26 23:16:08 +00:00
PN10QP27 (~128-bit security) drives a full BlindRotate inside lattice/v7@v7.1.0 — ~22s on Apple silicon. Comment added so future maintainers don't mistake the test wall-clock for a hang.
473 lines
12 KiB
Go
473 lines
12 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package encrypted
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/gob"
|
|
"testing"
|
|
|
|
"github.com/luxfi/fhe"
|
|
)
|
|
|
|
// testEnv holds shared FHE key material. Keygen is expensive (~2s), so
|
|
// we do it once per test binary. PN10QP27 gives ~128-bit security.
|
|
type testEnv struct {
|
|
params fhe.Parameters
|
|
sk *fhe.SecretKey
|
|
enc *fhe.Encryptor
|
|
dec *fhe.Decryptor
|
|
eval *fhe.Evaluator
|
|
}
|
|
|
|
var env *testEnv
|
|
|
|
func setup(t *testing.T) *testEnv {
|
|
t.Helper()
|
|
// A single MergeLWW at PN10QP27 (~128-bit security) drives a full BlindRotate
|
|
// inside lattice/v7@v7.1.0 (~22s on Apple silicon). The encrypted-CRDT suite
|
|
// chains 18 such operations and blows past the default 60s test timeout. The
|
|
// underlying primitive is correct (see TestMergeLWW_LaterTimestampWins below
|
|
// — it passes in isolation under `-timeout=600s`), so we skip in `-short`
|
|
// mode rather than mask a real bug. Tracking issue: lux/fhe#crdt-blindrotate-perf
|
|
// (cut the inner BlindRotate down to ~1s via PN9QP18, or batch via packed-LWE).
|
|
if testing.Short() {
|
|
t.Skip("encrypted-CRDT BlindRotate suite is >>5min; tracked at #crdt-blindrotate-perf")
|
|
}
|
|
if env != nil {
|
|
return env
|
|
}
|
|
params, err := fhe.NewParametersFromLiteral(fhe.PN10QP27)
|
|
if err != nil {
|
|
t.Fatalf("params: %v", err)
|
|
}
|
|
keygen := fhe.NewKeyGenerator(params)
|
|
sk, _ := keygen.GenKeyPair()
|
|
bsk := keygen.GenBootstrapKey(sk)
|
|
env = &testEnv{
|
|
params: params,
|
|
sk: sk,
|
|
enc: fhe.NewEncryptor(params, sk),
|
|
dec: fhe.NewDecryptor(params, sk),
|
|
eval: fhe.NewEvaluator(params, bsk),
|
|
}
|
|
return env
|
|
}
|
|
|
|
// --- LWW Register ---
|
|
|
|
func TestMergeLWW_LaterTimestampWins(t *testing.T) {
|
|
e := setup(t)
|
|
// Node A: value=7, ts=3; Node B: value=12, ts=5.
|
|
// B wins because ts=5 > ts=3.
|
|
a := EncryptRegister(e.enc, 7, 3, 4, 4)
|
|
b := EncryptRegister(e.enc, 12, 5, 4, 4)
|
|
|
|
merged, err := MergeLWW(e.eval, a, b)
|
|
if err != nil {
|
|
t.Fatalf("merge: %v", err)
|
|
}
|
|
val, ts := DecryptRegister(e.dec, merged)
|
|
if val != 12 || ts != 5 {
|
|
t.Fatalf("expected val=12 ts=5, got val=%d ts=%d", val, ts)
|
|
}
|
|
}
|
|
|
|
func TestMergeLWW_Commutativity(t *testing.T) {
|
|
e := setup(t)
|
|
a := EncryptRegister(e.enc, 3, 1, 4, 4)
|
|
b := EncryptRegister(e.enc, 9, 6, 4, 4)
|
|
|
|
ab, err := MergeLWW(e.eval, a, b)
|
|
if err != nil {
|
|
t.Fatalf("merge a,b: %v", err)
|
|
}
|
|
ba, err := MergeLWW(e.eval, b, a)
|
|
if err != nil {
|
|
t.Fatalf("merge b,a: %v", err)
|
|
}
|
|
|
|
vAB, tAB := DecryptRegister(e.dec, ab)
|
|
vBA, tBA := DecryptRegister(e.dec, ba)
|
|
if vAB != vBA || tAB != tBA {
|
|
t.Fatalf("not commutative: merge(a,b)=(%d,%d) merge(b,a)=(%d,%d)", vAB, tAB, vBA, tBA)
|
|
}
|
|
}
|
|
|
|
func TestMergeLWWN_ThreeWay(t *testing.T) {
|
|
e := setup(t)
|
|
// Three concurrent writes; ts=7 should win.
|
|
r1 := EncryptRegister(e.enc, 1, 2, 4, 4)
|
|
r2 := EncryptRegister(e.enc, 5, 7, 4, 4)
|
|
r3 := EncryptRegister(e.enc, 9, 4, 4, 4)
|
|
|
|
merged, err := MergeLWWN(e.eval, r1, r2, r3)
|
|
if err != nil {
|
|
t.Fatalf("merge: %v", err)
|
|
}
|
|
val, ts := DecryptRegister(e.dec, merged)
|
|
if val != 5 || ts != 7 {
|
|
t.Fatalf("expected val=5 ts=7, got val=%d ts=%d", val, ts)
|
|
}
|
|
}
|
|
|
|
func TestMergeLWW_EqualTimestampPicksSmaller(t *testing.T) {
|
|
e := setup(t)
|
|
// Same timestamp => smaller value wins (deterministic tie-break).
|
|
a := EncryptRegister(e.enc, 3, 5, 4, 4)
|
|
b := EncryptRegister(e.enc, 9, 5, 4, 4)
|
|
|
|
// merge(a, b) should pick val=3 (smaller).
|
|
ab, err := MergeLWW(e.eval, a, b)
|
|
if err != nil {
|
|
t.Fatalf("merge(a,b): %v", err)
|
|
}
|
|
val, _ := DecryptRegister(e.dec, ab)
|
|
if val != 3 {
|
|
t.Fatalf("merge(a,b): expected val=3 (smaller), got %d", val)
|
|
}
|
|
|
|
// merge(b, a) should also pick val=3 (commutative tie-break).
|
|
ba, err := MergeLWW(e.eval, b, a)
|
|
if err != nil {
|
|
t.Fatalf("merge(b,a): %v", err)
|
|
}
|
|
val2, _ := DecryptRegister(e.dec, ba)
|
|
if val2 != 3 {
|
|
t.Fatalf("merge(b,a): expected val=3 (commutative), got %d", val2)
|
|
}
|
|
}
|
|
|
|
func TestMergeLWWN_EqualTimestamps_Deterministic(t *testing.T) {
|
|
e := setup(t)
|
|
// 5 registers with same timestamp, different values.
|
|
vals := []uint64{7, 2, 11, 5, 2}
|
|
regs := make([]*Register, len(vals))
|
|
for i, v := range vals {
|
|
regs[i] = EncryptRegister(e.enc, v, 10, 4, 4)
|
|
}
|
|
|
|
// Merge all permutations and verify the same value wins.
|
|
baseline, err := MergeLWWN(e.eval, regs...)
|
|
if err != nil {
|
|
t.Fatalf("merge: %v", err)
|
|
}
|
|
bVal, bTS := DecryptRegister(e.dec, baseline)
|
|
if bVal != 2 {
|
|
t.Fatalf("expected val=2 (smallest), got %d", bVal)
|
|
}
|
|
|
|
// Reverse order should produce same result.
|
|
reversed := make([]*Register, len(regs))
|
|
for i := range regs {
|
|
reversed[i] = regs[len(regs)-1-i]
|
|
}
|
|
rev, err := MergeLWWN(e.eval, reversed...)
|
|
if err != nil {
|
|
t.Fatalf("merge reversed: %v", err)
|
|
}
|
|
rVal, rTS := DecryptRegister(e.dec, rev)
|
|
if rVal != bVal || rTS != bTS {
|
|
t.Fatalf("not permutation-invariant: forward=(%d,%d) reverse=(%d,%d)", bVal, bTS, rVal, rTS)
|
|
}
|
|
}
|
|
|
|
func TestMergeLWW_BitWidthMismatchErrors(t *testing.T) {
|
|
e := setup(t)
|
|
a := EncryptRegister(e.enc, 1, 1, 4, 4)
|
|
b := EncryptRegister(e.enc, 1, 1, 8, 4)
|
|
_, err := MergeLWW(e.eval, a, b)
|
|
if err == nil {
|
|
t.Fatal("expected error on bit-width mismatch")
|
|
}
|
|
}
|
|
|
|
// --- Register serialization ---
|
|
|
|
func TestRegisterMarshalRoundTrip(t *testing.T) {
|
|
e := setup(t)
|
|
orig := EncryptRegister(e.enc, 11, 6, 4, 4)
|
|
|
|
data, err := MarshalRegister(orig)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
restored, err := UnmarshalRegister(data)
|
|
if err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
val, ts := DecryptRegister(e.dec, restored)
|
|
if val != 11 || ts != 6 {
|
|
t.Fatalf("round-trip failed: val=%d ts=%d", val, ts)
|
|
}
|
|
}
|
|
|
|
// --- Deserialization bounds ---
|
|
|
|
func TestDecodeCiphertexts_RejectsOversized(t *testing.T) {
|
|
// Attempting to unmarshal a Register with BitsVal=257 should fail.
|
|
var buf bytes.Buffer
|
|
enc := gob.NewEncoder(&buf)
|
|
enc.Encode(257) // BitsVal
|
|
enc.Encode(4) // BitsTS
|
|
|
|
_, err := UnmarshalRegister(buf.Bytes())
|
|
if err == nil {
|
|
t.Fatal("expected error for BitsVal > maxBits")
|
|
}
|
|
|
|
// BitsTS too large.
|
|
buf.Reset()
|
|
enc = gob.NewEncoder(&buf)
|
|
enc.Encode(4) // BitsVal OK
|
|
enc.Encode(300) // BitsTS too large
|
|
|
|
_, err = UnmarshalRegister(buf.Bytes())
|
|
if err == nil {
|
|
t.Fatal("expected error for BitsTS > maxBits")
|
|
}
|
|
}
|
|
|
|
func TestUnmarshalORSet_RejectsOversized(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
enc := gob.NewEncoder(&buf)
|
|
enc.Encode(65537) // count > maxORSetElements
|
|
|
|
_, err := UnmarshalORSet(buf.Bytes())
|
|
if err == nil {
|
|
t.Fatal("expected error for ORSet count > maxORSetElements")
|
|
}
|
|
}
|
|
|
|
// --- OR-Set ---
|
|
|
|
func TestORSet_MergeIsTagUnion(t *testing.T) {
|
|
e := setup(t)
|
|
|
|
a := NewORSet()
|
|
a.Add("nodeA:1", encryptUint(e.enc, 42, 8), 8)
|
|
a.Add("nodeA:2", encryptUint(e.enc, 99, 8), 8)
|
|
|
|
b := NewORSet()
|
|
b.Add("nodeB:1", encryptUint(e.enc, 7, 8), 8)
|
|
b.Add("nodeA:1", encryptUint(e.enc, 55, 8), 8) // overlapping tag
|
|
|
|
merged := MergeORSet(a, b)
|
|
if merged.Len() != 3 {
|
|
t.Fatalf("expected 3 entries, got %d", merged.Len())
|
|
}
|
|
|
|
// nodeA:1 should have b's value (last-writer on tag collision).
|
|
entry := merged.Get("nodeA:1")
|
|
val := decryptUint(e.dec, entry.Value)
|
|
if val != 55 {
|
|
t.Fatalf("expected tag collision to pick b's value (55), got %d", val)
|
|
}
|
|
}
|
|
|
|
func TestORSet_PrivateTagsHidesMembership(t *testing.T) {
|
|
e := setup(t)
|
|
key := []byte("secret-document-key")
|
|
s := NewPrivateORSet(key)
|
|
s.Add("alice:1", encryptUint(e.enc, 42, 8), 8)
|
|
|
|
// Raw tag "alice:1" should not appear in the internal map.
|
|
for k := range s.elems {
|
|
if k == "alice:1" {
|
|
t.Fatal("plaintext tag leaked in private ORSet")
|
|
}
|
|
}
|
|
|
|
// But Contains/Get with the original tag should work.
|
|
if !s.Contains("alice:1") {
|
|
t.Fatal("private ORSet: Contains failed for wrapped tag")
|
|
}
|
|
entry := s.Get("alice:1")
|
|
if entry == nil {
|
|
t.Fatal("private ORSet: Get returned nil")
|
|
}
|
|
val := decryptUint(e.dec, entry.Value)
|
|
if val != 42 {
|
|
t.Fatalf("expected 42, got %d", val)
|
|
}
|
|
|
|
s.Remove("alice:1")
|
|
if s.Contains("alice:1") {
|
|
t.Fatal("private ORSet: Remove failed")
|
|
}
|
|
}
|
|
|
|
func TestORSet_RemoveAndContains(t *testing.T) {
|
|
e := setup(t)
|
|
s := NewORSet()
|
|
s.Add("x:1", encryptUint(e.enc, 1, 4), 4)
|
|
if !s.Contains("x:1") {
|
|
t.Fatal("should contain x:1")
|
|
}
|
|
s.Remove("x:1")
|
|
if s.Contains("x:1") {
|
|
t.Fatal("should not contain x:1 after remove")
|
|
}
|
|
}
|
|
|
|
// --- GCounter ---
|
|
|
|
func TestGCounter_RejectsUnauthorizedNode(t *testing.T) {
|
|
e := setup(t)
|
|
bits := 4
|
|
g := NewGCounter(bits, "nodeA", "nodeB")
|
|
|
|
err := g.Set("nodeA", encryptUint(e.enc, 1, bits))
|
|
if err != nil {
|
|
t.Fatalf("authorized nodeA should succeed: %v", err)
|
|
}
|
|
|
|
err = g.Set("evil", encryptUint(e.enc, 1, bits))
|
|
if err == nil {
|
|
t.Fatal("expected error for unauthorized nodeID")
|
|
}
|
|
}
|
|
|
|
func TestGCounter_MergeRejectsUnauthorizedNode(t *testing.T) {
|
|
e := setup(t)
|
|
bits := 4
|
|
a := NewGCounter(bits, "nodeA", "nodeB")
|
|
a.Set("nodeA", encryptUint(e.enc, 5, bits))
|
|
|
|
// b has an unauthorized nodeID.
|
|
b := NewGCounter(bits)
|
|
b.Set("evil", encryptUint(e.enc, 1, bits))
|
|
|
|
_, err := MergeGCounter(e.eval, a, b)
|
|
if err == nil {
|
|
t.Fatal("expected error for unauthorized nodeID during merge")
|
|
}
|
|
}
|
|
|
|
func TestGCounter_OpenMode(t *testing.T) {
|
|
e := setup(t)
|
|
bits := 4
|
|
g := NewGCounter(bits) // no authorized nodes = open mode
|
|
err := g.Set("anyone", encryptUint(e.enc, 1, bits))
|
|
if err != nil {
|
|
t.Fatalf("open mode should accept any nodeID: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGCounter_MergeHomomorphicMax(t *testing.T) {
|
|
e := setup(t)
|
|
bits := 4
|
|
|
|
a := NewGCounter(bits, "nodeA", "nodeB", "nodeC")
|
|
a.Set("nodeA", encryptUint(e.enc, 5, bits))
|
|
a.Set("nodeB", encryptUint(e.enc, 3, bits))
|
|
|
|
b := NewGCounter(bits, "nodeA", "nodeB", "nodeC")
|
|
b.Set("nodeA", encryptUint(e.enc, 2, bits))
|
|
b.Set("nodeB", encryptUint(e.enc, 7, bits))
|
|
b.Set("nodeC", encryptUint(e.enc, 4, bits))
|
|
|
|
merged, err := MergeGCounter(e.eval, a, b)
|
|
if err != nil {
|
|
t.Fatalf("merge: %v", err)
|
|
}
|
|
|
|
// nodeA: max(5,2)=5, nodeB: max(3,7)=7, nodeC: 4 (only in b)
|
|
checkNode := func(nid string, expected uint64) {
|
|
t.Helper()
|
|
cts := merged.Get(nid)
|
|
if cts == nil {
|
|
t.Fatalf("missing node %s", nid)
|
|
}
|
|
got := decryptUint(e.dec, cts)
|
|
if got != expected {
|
|
t.Fatalf("node %s: expected %d, got %d", nid, expected, got)
|
|
}
|
|
}
|
|
checkNode("nodeA", 5)
|
|
checkNode("nodeB", 7)
|
|
checkNode("nodeC", 4)
|
|
}
|
|
|
|
// --- Document ---
|
|
|
|
func TestDocument_MergeAndStateRoot(t *testing.T) {
|
|
e := setup(t)
|
|
|
|
doc1 := NewDocument("doc-1")
|
|
doc1.SetRegister("owner", EncryptRegister(e.enc, 1, 10, 4, 4))
|
|
|
|
set1 := NewORSet()
|
|
set1.Add("tag:a", encryptUint(e.enc, 42, 8), 8)
|
|
doc1.SetORSet("members", set1)
|
|
|
|
doc2 := NewDocument("doc-1")
|
|
doc2.SetRegister("owner", EncryptRegister(e.enc, 2, 15, 4, 4))
|
|
|
|
set2 := NewORSet()
|
|
set2.Add("tag:b", encryptUint(e.enc, 99, 8), 8)
|
|
doc2.SetORSet("members", set2)
|
|
|
|
if err := doc1.Merge(e.eval, doc2); err != nil {
|
|
t.Fatalf("merge: %v", err)
|
|
}
|
|
|
|
// owner should be doc2's value (ts=15 > ts=10).
|
|
val, ts := DecryptRegister(e.dec, doc1.GetRegister("owner"))
|
|
if val != 2 || ts != 15 {
|
|
t.Fatalf("expected owner=2 ts=15, got %d %d", val, ts)
|
|
}
|
|
|
|
// members should have both tags.
|
|
members := doc1.GetORSet("members")
|
|
if members.Len() != 2 {
|
|
t.Fatalf("expected 2 members, got %d", members.Len())
|
|
}
|
|
|
|
// StateRoot should be deterministic.
|
|
r1, err := doc1.StateRoot()
|
|
if err != nil {
|
|
t.Fatalf("root: %v", err)
|
|
}
|
|
r2, err := doc1.StateRoot()
|
|
if err != nil {
|
|
t.Fatalf("root2: %v", err)
|
|
}
|
|
if r1 != r2 {
|
|
t.Fatal("state root not deterministic")
|
|
}
|
|
}
|
|
|
|
func TestDocument_MarshalRoundTrip(t *testing.T) {
|
|
e := setup(t)
|
|
|
|
doc := NewDocument("roundtrip")
|
|
doc.SetRegister("field1", EncryptRegister(e.enc, 15, 3, 4, 4))
|
|
s := NewORSet()
|
|
s.Add("t:1", encryptUint(e.enc, 7, 4), 4)
|
|
doc.SetORSet("set1", s)
|
|
|
|
data, err := doc.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
|
|
doc2 := &Document{}
|
|
if err := doc2.UnmarshalBinary(data); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
if doc2.ID() != "roundtrip" {
|
|
t.Fatalf("id mismatch: %s", doc2.ID())
|
|
}
|
|
val, ts := DecryptRegister(e.dec, doc2.GetRegister("field1"))
|
|
if val != 15 || ts != 3 {
|
|
t.Fatalf("register round-trip: val=%d ts=%d", val, ts)
|
|
}
|
|
if doc2.GetORSet("set1").Len() != 1 {
|
|
t.Fatal("orset lost entries")
|
|
}
|
|
}
|