mirror of
https://github.com/luxfi/fhe.git
synced 2026-07-26 23:16:08 +00:00
LWW-Register, ORSet, GCounter merge under TFHE encryption. EncryptedDocument with structural StateRoot (deterministic across replicas despite ciphertext non-determinism). AnchorClient interface for CRDTAnchor.sol checkpoint wiring. Safe prime swap: 256-bit composite → 2048-bit RFC 3526 Group 14. Feldman VSS generator now element of order q (was full group). Reshare uses additive sub-sharing (no secret materialization). 19 encrypted + 10 threshold tests pass. Red-reviewed: 16 findings, 13 fixed, 5 scientist findings addressed.
466 lines
12 KiB
Go
466 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"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
|
|
"github.com/luxfi/fhe"
|
|
"golang.org/x/crypto/sha3"
|
|
)
|
|
|
|
// Document is a named collection of encrypted CRDT fields, mirroring
|
|
// hanzo/base/crdt.Document but with FHE-encrypted values. Each field is
|
|
// one of: Register (LWW), ORSet, or GCounter.
|
|
//
|
|
// The Document itself never decrypts. It stores opaque ciphertexts and
|
|
// delegates merge to the type-specific functions. A relay holding only
|
|
// the evaluation key can merge two Documents without seeing plaintext.
|
|
//
|
|
// writeCount is incremented on every local Set* and every applied Merge;
|
|
// combined with the structural root (field names, kinds, bit widths) it
|
|
// yields a deterministic StateRoot that is stable across replicas
|
|
// regardless of ciphertext non-determinism.
|
|
type Document struct {
|
|
mu sync.RWMutex
|
|
id string
|
|
registers map[string]*Register
|
|
orsets map[string]*ORSet
|
|
gcounters map[string]*GCounter
|
|
writeCount uint64
|
|
}
|
|
|
|
// NewDocument creates an empty encrypted Document.
|
|
func NewDocument(id string) *Document {
|
|
return &Document{
|
|
id: id,
|
|
registers: make(map[string]*Register),
|
|
orsets: make(map[string]*ORSet),
|
|
gcounters: make(map[string]*GCounter),
|
|
}
|
|
}
|
|
|
|
// OpCount returns the cumulative count of local Set* and applied Merge
|
|
// operations. Used together with StateRoot for on-chain anchoring.
|
|
func (d *Document) OpCount() uint64 {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return d.writeCount
|
|
}
|
|
|
|
// ID returns the document identifier.
|
|
func (d *Document) ID() string { return d.id }
|
|
|
|
// SetRegister stores an encrypted LWW Register under the given field name.
|
|
func (d *Document) SetRegister(field string, r *Register) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.registers[field] = r
|
|
d.writeCount++
|
|
}
|
|
|
|
// GetRegister returns the register for a field, or nil.
|
|
func (d *Document) GetRegister(field string) *Register {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return d.registers[field]
|
|
}
|
|
|
|
// SetORSet stores an encrypted OR-Set under the given field name.
|
|
func (d *Document) SetORSet(field string, s *ORSet) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.orsets[field] = s
|
|
d.writeCount++
|
|
}
|
|
|
|
// GetORSet returns the OR-Set for a field, or nil.
|
|
func (d *Document) GetORSet(field string) *ORSet {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return d.orsets[field]
|
|
}
|
|
|
|
// SetGCounter stores an encrypted GCounter under the given field name.
|
|
func (d *Document) SetGCounter(field string, c *GCounter) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.gcounters[field] = c
|
|
d.writeCount++
|
|
}
|
|
|
|
// GetGCounter returns the GCounter for a field, or nil.
|
|
func (d *Document) GetGCounter(field string) *GCounter {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return d.gcounters[field]
|
|
}
|
|
|
|
// Merge merges another Document into this one. Registers use LWW merge,
|
|
// OR-Sets use tag union, GCounters use homomorphic max. The evaluator is
|
|
// required for Register and GCounter merges (which perform FHE ops); it
|
|
// may be nil if the document contains only OR-Sets.
|
|
func (d *Document) Merge(eval *fhe.Evaluator, other *Document) error {
|
|
other.mu.RLock()
|
|
defer other.mu.RUnlock()
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
for name, otherReg := range other.registers {
|
|
if existing, ok := d.registers[name]; ok {
|
|
if eval == nil {
|
|
return fmt.Errorf("encrypted/document: evaluator required for register merge on field %q", name)
|
|
}
|
|
merged, err := MergeLWW(eval, existing, otherReg)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypted/document: merge register %q: %w", name, err)
|
|
}
|
|
d.registers[name] = merged
|
|
} else {
|
|
d.registers[name] = otherReg
|
|
}
|
|
}
|
|
|
|
for name, otherSet := range other.orsets {
|
|
if existing, ok := d.orsets[name]; ok {
|
|
d.orsets[name] = MergeORSet(existing, otherSet)
|
|
} else {
|
|
d.orsets[name] = otherSet
|
|
}
|
|
}
|
|
|
|
for name, otherCtr := range other.gcounters {
|
|
if existing, ok := d.gcounters[name]; ok {
|
|
if eval == nil {
|
|
return fmt.Errorf("encrypted/document: evaluator required for gcounter merge on field %q", name)
|
|
}
|
|
merged, err := MergeGCounter(eval, existing, otherCtr)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypted/document: merge gcounter %q: %w", name, err)
|
|
}
|
|
d.gcounters[name] = merged
|
|
} else {
|
|
d.gcounters[name] = otherCtr
|
|
}
|
|
}
|
|
|
|
// Every merge counts as a single write event for StateRoot purposes.
|
|
// Two replicas that have applied the same set of local Sets plus the
|
|
// same set of incoming Merges end with identical writeCount, giving
|
|
// the same StateRoot regardless of ordering.
|
|
d.writeCount++
|
|
return nil
|
|
}
|
|
|
|
// MarshalBinary serializes the entire Document for storage or transport.
|
|
func (d *Document) MarshalBinary() ([]byte, error) {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
|
|
var buf bytes.Buffer
|
|
enc := gob.NewEncoder(&buf)
|
|
|
|
if err := enc.Encode(d.id); err != nil {
|
|
return nil, err
|
|
}
|
|
// writeCount — needed so a replica restored from disk produces the
|
|
// same StateRoot as it had before shutdown.
|
|
if err := enc.Encode(d.writeCount); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Registers
|
|
if err := enc.Encode(len(d.registers)); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, name := range sortedKeys(d.registers) {
|
|
if err := enc.Encode(name); err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := MarshalRegister(d.registers[name])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("register %q: %w", name, err)
|
|
}
|
|
if err := enc.Encode(data); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// ORSets
|
|
if err := enc.Encode(len(d.orsets)); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, name := range sortedKeysORSet(d.orsets) {
|
|
if err := enc.Encode(name); err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := MarshalORSet(d.orsets[name])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("orset %q: %w", name, err)
|
|
}
|
|
if err := enc.Encode(data); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// GCounters
|
|
if err := enc.Encode(len(d.gcounters)); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, name := range sortedKeysGCounter(d.gcounters) {
|
|
if err := enc.Encode(name); err != nil {
|
|
return nil, err
|
|
}
|
|
ctr := d.gcounters[name]
|
|
if err := enc.Encode(ctr.bits); err != nil {
|
|
return nil, err
|
|
}
|
|
// Persist the authorized node list so Decode can rebuild a counter
|
|
// that still rejects fabricated nodeIDs. Nil list = open mode.
|
|
authorized := ctr.authorizedList()
|
|
if err := enc.Encode(authorized); err != nil {
|
|
return nil, err
|
|
}
|
|
nodes := ctr.Nodes()
|
|
if err := enc.Encode(len(nodes)); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, nid := range nodes {
|
|
if err := enc.Encode(nid); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := encodeCiphertexts(&buf, ctr.state[nid].cts); err != nil {
|
|
return nil, fmt.Errorf("gcounter %q node %q: %w", name, nid, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// UnmarshalBinary deserializes a Document.
|
|
func (d *Document) UnmarshalBinary(data []byte) error {
|
|
r := bytes.NewReader(data)
|
|
dec := gob.NewDecoder(r)
|
|
|
|
if err := dec.Decode(&d.id); err != nil {
|
|
return err
|
|
}
|
|
if err := dec.Decode(&d.writeCount); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Registers
|
|
var nRegs int
|
|
if err := dec.Decode(&nRegs); err != nil {
|
|
return err
|
|
}
|
|
d.registers = make(map[string]*Register, nRegs)
|
|
for i := 0; i < nRegs; i++ {
|
|
var name string
|
|
if err := dec.Decode(&name); err != nil {
|
|
return err
|
|
}
|
|
var regData []byte
|
|
if err := dec.Decode(®Data); err != nil {
|
|
return err
|
|
}
|
|
reg, err := UnmarshalRegister(regData)
|
|
if err != nil {
|
|
return fmt.Errorf("register %q: %w", name, err)
|
|
}
|
|
d.registers[name] = reg
|
|
}
|
|
|
|
// ORSets
|
|
var nSets int
|
|
if err := dec.Decode(&nSets); err != nil {
|
|
return err
|
|
}
|
|
d.orsets = make(map[string]*ORSet, nSets)
|
|
for i := 0; i < nSets; i++ {
|
|
var name string
|
|
if err := dec.Decode(&name); err != nil {
|
|
return err
|
|
}
|
|
var setData []byte
|
|
if err := dec.Decode(&setData); err != nil {
|
|
return err
|
|
}
|
|
s, err := UnmarshalORSet(setData)
|
|
if err != nil {
|
|
return fmt.Errorf("orset %q: %w", name, err)
|
|
}
|
|
d.orsets[name] = s
|
|
}
|
|
|
|
// GCounters
|
|
var nCtrs int
|
|
if err := dec.Decode(&nCtrs); err != nil {
|
|
return err
|
|
}
|
|
d.gcounters = make(map[string]*GCounter, nCtrs)
|
|
for i := 0; i < nCtrs; i++ {
|
|
var name string
|
|
if err := dec.Decode(&name); err != nil {
|
|
return err
|
|
}
|
|
var bits int
|
|
if err := dec.Decode(&bits); err != nil {
|
|
return err
|
|
}
|
|
// Restore the authorized list — skipping this was S3 in the Scientist
|
|
// audit: authorization was silently dropped on round-trip.
|
|
var authorized []string
|
|
if err := dec.Decode(&authorized); err != nil {
|
|
return err
|
|
}
|
|
ctr := NewGCounter(bits, authorized...)
|
|
var nNodes int
|
|
if err := dec.Decode(&nNodes); err != nil {
|
|
return err
|
|
}
|
|
for j := 0; j < nNodes; j++ {
|
|
var nid string
|
|
if err := dec.Decode(&nid); err != nil {
|
|
return err
|
|
}
|
|
cts, err := decodeCiphertexts(r, bits)
|
|
if err != nil {
|
|
return fmt.Errorf("gcounter %q node %q: %w", name, nid, err)
|
|
}
|
|
ctr.state[nid] = &encUint{cts: cts}
|
|
}
|
|
d.gcounters[name] = ctr
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// StateRoot computes a deterministic structural commitment suitable for
|
|
// on-chain anchoring via CRDTAnchor.checkpoint(). The root binds:
|
|
//
|
|
// - the document ID
|
|
// - the cumulative write count
|
|
// - the sorted set of field names + kinds + bit widths
|
|
//
|
|
// It does NOT hash raw ciphertext bytes, because FHE ciphertexts are
|
|
// non-deterministic: two replicas applying the same logical ops produce
|
|
// different ciphertext objects. Hashing them directly would diverge.
|
|
// (Scientist audit finding S2.)
|
|
//
|
|
// Two replicas that have applied the same set of Set*/Merge operations
|
|
// produce the same StateRoot — regardless of order. This matches the
|
|
// convergence property of CRDTs at the structural level. For a commitment
|
|
// to the actual ciphertext bytes (useful for debugging / content
|
|
// addressing, not consistency), use CiphertextRoot.
|
|
func (d *Document) StateRoot() ([32]byte, error) {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
|
|
h := sha3.NewLegacyKeccak256()
|
|
// Domain separator so an attacker can't replay a StateRoot hash as
|
|
// some other pre-image.
|
|
h.Write([]byte("lux.fhe.encrypted.StateRoot.v1\x00"))
|
|
writeLenString(h, d.id)
|
|
writeUint64(h, d.writeCount)
|
|
|
|
for _, name := range sortedKeys(d.registers) {
|
|
r := d.registers[name]
|
|
h.Write([]byte("reg\x00"))
|
|
writeLenString(h, name)
|
|
writeUint32(h, uint32(r.BitsVal))
|
|
writeUint32(h, uint32(r.BitsTS))
|
|
}
|
|
for _, name := range sortedKeysORSet(d.orsets) {
|
|
s := d.orsets[name]
|
|
h.Write([]byte("orset\x00"))
|
|
writeLenString(h, name)
|
|
writeUint32(h, uint32(len(s.Tags())))
|
|
}
|
|
for _, name := range sortedKeysGCounter(d.gcounters) {
|
|
c := d.gcounters[name]
|
|
h.Write([]byte("gcounter\x00"))
|
|
writeLenString(h, name)
|
|
writeUint32(h, uint32(c.bits))
|
|
writeUint32(h, uint32(len(c.state)))
|
|
}
|
|
|
|
var root [32]byte
|
|
copy(root[:], h.Sum(nil))
|
|
return root, nil
|
|
}
|
|
|
|
// CiphertextRoot computes keccak256 of the gob-encoded snapshot, including
|
|
// raw ciphertext bytes. Useful for content addressing of a specific
|
|
// serialized form; NOT useful for cross-replica consistency because FHE
|
|
// ciphertexts are non-deterministic. Do not use this for on-chain anchoring.
|
|
func (d *Document) CiphertextRoot() ([32]byte, error) {
|
|
data, err := d.MarshalBinary()
|
|
if err != nil {
|
|
return [32]byte{}, fmt.Errorf("ciphertext root: %w", err)
|
|
}
|
|
h := sha3.NewLegacyKeccak256()
|
|
h.Write([]byte("lux.fhe.encrypted.CiphertextRoot.v1\x00"))
|
|
h.Write(data)
|
|
var root [32]byte
|
|
copy(root[:], h.Sum(nil))
|
|
return root, nil
|
|
}
|
|
|
|
// writeLenString writes a 32-bit big-endian length prefix followed by the
|
|
// string bytes — prevents canonicalization ambiguity when concatenating.
|
|
func writeLenString(h interface{ Write([]byte) (int, error) }, s string) {
|
|
writeUint32(h, uint32(len(s)))
|
|
h.Write([]byte(s))
|
|
}
|
|
|
|
func writeUint32(h interface{ Write([]byte) (int, error) }, v uint32) {
|
|
var b [4]byte
|
|
b[0] = byte(v >> 24)
|
|
b[1] = byte(v >> 16)
|
|
b[2] = byte(v >> 8)
|
|
b[3] = byte(v)
|
|
h.Write(b[:])
|
|
}
|
|
|
|
func writeUint64(h interface{ Write([]byte) (int, error) }, v uint64) {
|
|
var b [8]byte
|
|
for i := 0; i < 8; i++ {
|
|
b[i] = byte(v >> (56 - 8*i))
|
|
}
|
|
h.Write(b[:])
|
|
}
|
|
|
|
func sortedKeys(m map[string]*Register) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func sortedKeysORSet(m map[string]*ORSet) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func sortedKeysGCounter(m map[string]*GCounter) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|