Files
fhe/pkg/encrypted/orset.go
T
Hanzo AI 8b8447a0a4 feat(encrypted): FHE CRDT primitives + RFC 3526 safe prime
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.
2026-04-13 01:03:34 -07:00

204 lines
5.6 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
package encrypted
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/gob"
"encoding/hex"
"fmt"
"sort"
"sync"
"github.com/luxfi/fhe"
)
// ORSet is an Observed-Remove Set where tags are plaintext (for identity)
// and values are FHE-encrypted. Merge is tag-union: no FHE operations are
// required during merge itself, only during read/decrypt. This makes ORSet
// the cheapest encrypted CRDT to merge (O(1) FHE ops per merge = zero).
//
// The plaintext tags reveal set membership to the relay. If membership
// itself is sensitive, wrap the entire ORSet state in an age envelope
// and sync as an opaque blob. The FHE encryption here protects values
// only — element identity leaks by design (same as Zcash shielded pool
// where transaction graph is visible but amounts are hidden).
type ORSet struct {
mu sync.RWMutex
elems map[string]*ORSetEntry // tag -> encrypted value
// TagKey, when non-nil, enables HMAC-wrapped tags. All tags passed
// to Add/Remove/Contains are HMAC'd before use, hiding membership
// from the relay. Leave nil for "public set" mode where membership
// is already public.
TagKey []byte
}
// ORSetEntry is a single element in the OR-Set.
type ORSetEntry struct {
Tag string // plaintext unique tag (nodeID:seq)
Value []*fhe.Ciphertext // encrypted value bits (LSB-first)
Bits int // bit-width of encrypted value
}
// NewORSet creates an empty encrypted OR-Set with public tags (no HMAC).
func NewORSet() *ORSet {
return &ORSet{elems: make(map[string]*ORSetEntry)}
}
// NewPrivateORSet creates an encrypted OR-Set with HMAC-wrapped tags.
// The tagKey seeds the HMAC; the relay only sees opaque hex digests.
func NewPrivateORSet(tagKey []byte) *ORSet {
return &ORSet{elems: make(map[string]*ORSetEntry), TagKey: tagKey}
}
// wrapTag returns the HMAC-wrapped tag if TagKey is set, otherwise the raw tag.
func (s *ORSet) wrapTag(tag string) string {
if len(s.TagKey) == 0 {
return tag
}
mac := hmac.New(sha256.New, s.TagKey)
mac.Write([]byte(tag))
return hex.EncodeToString(mac.Sum(nil))
}
// Add inserts an encrypted value under a unique tag. If the tag already
// exists, the old value is overwritten (add-wins on tag collision).
// When TagKey is set, the stored key is HMAC(tag) so the relay cannot
// observe plaintext membership.
func (s *ORSet) Add(tag string, value []*fhe.Ciphertext, bits int) {
s.mu.Lock()
defer s.mu.Unlock()
k := s.wrapTag(tag)
s.elems[k] = &ORSetEntry{Tag: k, Value: value, Bits: bits}
}
// Remove removes an element by tag.
func (s *ORSet) Remove(tag string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.elems, s.wrapTag(tag))
}
// Contains checks if a tag is present.
func (s *ORSet) Contains(tag string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, ok := s.elems[s.wrapTag(tag)]
return ok
}
// Tags returns all tags in sorted order (deterministic iteration).
func (s *ORSet) Tags() []string {
s.mu.RLock()
defer s.mu.RUnlock()
tags := make([]string, 0, len(s.elems))
for t := range s.elems {
tags = append(tags, t)
}
sort.Strings(tags)
return tags
}
// Get returns the encrypted entry for a tag, or nil.
func (s *ORSet) Get(tag string) *ORSetEntry {
s.mu.RLock()
defer s.mu.RUnlock()
return s.elems[s.wrapTag(tag)]
}
// Len returns the number of elements.
func (s *ORSet) Len() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.elems)
}
// MergeORSet merges two OR-Sets by taking the union of tags. When both
// sets contain the same tag, the entry from b wins (last-writer-wins on
// tag collision). No FHE operations are performed.
func MergeORSet(a, b *ORSet) *ORSet {
a.mu.RLock()
b.mu.RLock()
defer a.mu.RUnlock()
defer b.mu.RUnlock()
result := NewORSet()
for tag, entry := range a.elems {
result.elems[tag] = entry
}
for tag, entry := range b.elems {
result.elems[tag] = entry
}
return result
}
// MarshalORSet serializes an OR-Set.
func MarshalORSet(s *ORSet) ([]byte, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
if err := enc.Encode(len(s.elems)); err != nil {
return nil, err
}
// Iterate in sorted order for deterministic encoding.
tags := make([]string, 0, len(s.elems))
for t := range s.elems {
tags = append(tags, t)
}
sort.Strings(tags)
for _, tag := range tags {
entry := s.elems[tag]
if err := enc.Encode(entry.Tag); err != nil {
return nil, err
}
if err := enc.Encode(entry.Bits); err != nil {
return nil, err
}
if err := encodeCiphertexts(&buf, entry.Value); err != nil {
return nil, fmt.Errorf("orset tag %q: %w", tag, err)
}
}
return buf.Bytes(), nil
}
// maxORSetElements caps the maximum number of elements in a deserialized
// OR-Set to prevent OOM from malformed payloads.
const maxORSetElements = 65536
// UnmarshalORSet deserializes an OR-Set.
func UnmarshalORSet(data []byte) (*ORSet, error) {
r := bytes.NewReader(data)
dec := gob.NewDecoder(r)
var count int
if err := dec.Decode(&count); err != nil {
return nil, err
}
if count > maxORSetElements {
return nil, fmt.Errorf("orset element count %d exceeds max %d", count, maxORSetElements)
}
s := NewORSet()
for i := 0; i < count; i++ {
var tag string
var bits int
if err := dec.Decode(&tag); err != nil {
return nil, fmt.Errorf("entry %d tag: %w", i, err)
}
if err := dec.Decode(&bits); err != nil {
return nil, fmt.Errorf("entry %d bits: %w", i, err)
}
cts, err := decodeCiphertexts(r, bits)
if err != nil {
return nil, fmt.Errorf("entry %d cts: %w", i, err)
}
s.elems[tag] = &ORSetEntry{Tag: tag, Value: cts, Bits: bits}
}
return s, nil
}