Files
node/vms/chainadapter/messaging/messaging.go
T
Hanzo AI c3b398bc7b json: migrate every encoding/json import to json/v2 (go-json-experiment)
External (HTTP / JSON-RPC) is the only place JSON is legitimate. Every
existing encoding/json import in node/ moves to github.com/go-json-experiment/json
(v2 root, not the v1 sub-package). NewEncoder/NewDecoder rewrite to
MarshalWrite/UnmarshalRead. MarshalIndent rewrites to Marshal with
jsontext.WithIndent. json.RawMessage rewrites to jsontext.Value.
*json.SyntaxError rewrites to *jsontext.SyntacticError.

81 files migrated. LLM.md captures the rule + v1->v2 delta table.

Known v2 semantic deltas surfaced by existing tests (followups, not regressions):
- [N]byte fields with no MarshalJSON now marshal as base64 string (v1 marshalled
  as JSON array of byte numbers). Affects vms/platformvm/txs/*_test.go fixtures
  with embedded BLS proofOfPossession.
- time.Duration has no v2 default representation; configs that wire-format
  Duration as nanoseconds (vms/{xvm,platformvm}/config, config/spec) need to
  switch to string-form Duration or carry an explicit option. v2 root does not
  re-export FormatDurationAsNano.
- v2 enforces strict UTF-8 (vms/chainadapter/messaging fixture has non-UTF-8).
- json.MarshalWrite does not append a trailing '\n' (v1 NewEncoder.Encode did);
  service/auth/auth_test.go expectation updated.
- nil []byte round-trips to empty (not nil); config_test deep-equal fixtures
  surface this.

All affected sites are at the API boundary; ZAP wire envelope already covers
the internal data paths (state, P2P, consensus, MPC, threshold). Internal
JSON sites that should move to ZAP next (separate work):
- vms/da/store.go            (DA blob/cert storage as JSON)
- vms/platformvm/airdrop     (airdrop claims as JSON in db)
- vms/chainadapter/appchain  (SQLite materializer schema/data blobs)
- vms/chainadapter/messaging (conversation codec)
- staking/kms.go             (KMS HTTP client — external technically, leave)
- utils/{bimap,ips}          (small marshaler shims — low priority)
2026-06-06 22:26:02 -07:00

587 lines
15 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package messaging provides FHE-CRDT extensions for private messaging.
// It enables encrypted CRDTs for conversation metadata, group membership,
// read markers, and rate-limit counters integrated with service node storage.
package messaging
import (
"context"
"crypto/sha256"
"encoding/binary"
"github.com/go-json-experiment/json"
"errors"
"sync"
"time"
"github.com/luxfi/ids"
)
// Errors
var (
ErrConversationNotFound = errors.New("conversation not found")
ErrMemberNotFound = errors.New("member not found")
ErrRateLimitExceeded = errors.New("rate limit exceeded")
ErrEncryptionFailed = errors.New("encryption failed")
ErrDecryptionFailed = errors.New("decryption failed")
ErrInvalidSignature = errors.New("invalid signature")
ErrNotMember = errors.New("not a member of this conversation")
)
// ConversationType defines the type of conversation
type ConversationType uint8
const (
ConversationDirect ConversationType = iota
ConversationGroup
ConversationBroadcast
)
// Conversation represents an encrypted conversation with CRDT metadata
type Conversation struct {
ID ids.ID `json:"id"`
Type ConversationType `json:"type"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Encrypted metadata
EncryptedName []byte `json:"encryptedName,omitempty"`
EncryptedDescription []byte `json:"encryptedDescription,omitempty"`
EncryptedAvatar []byte `json:"encryptedAvatar,omitempty"`
// CRDT state
MembersCRDT *MembershipCRDT `json:"membersCrdt"`
ReadMarkersCRDT *ReadMarkerCRDT `json:"readMarkersCrdt"`
// Key management
KeyRotationEpoch uint64 `json:"keyRotationEpoch"`
EncryptedKeys [][]byte `json:"encryptedKeys"` // Per-member encrypted session key
// Settings
DisappearingMessages bool `json:"disappearingMessages"`
MessageTTL int64 `json:"messageTtl"` // Seconds
// Version for optimistic concurrency
Version [32]byte `json:"version"`
}
// Hash returns the hash of the conversation
func (c *Conversation) Hash() [32]byte {
h := sha256.New()
h.Write(c.ID[:])
binary.Write(h, binary.BigEndian, uint8(c.Type))
h.Write(c.Version[:])
return sha256.Sum256(h.Sum(nil))
}
// MembershipCRDT tracks group membership using an OR-Set CRDT
type MembershipCRDT struct {
Added map[string]*MemberEntry `json:"added"` // uniqueTag -> MemberEntry
Removed map[string]time.Time `json:"removed"` // uniqueTag -> removal time
mu sync.RWMutex
}
// MemberEntry represents a member in the membership CRDT
type MemberEntry struct {
AccountID [32]byte `json:"accountId"`
Role string `json:"role"` // "admin", "member", "viewer"
AddedAt time.Time `json:"addedAt"`
AddedBy [32]byte `json:"addedBy"`
// Encrypted member-specific data
EncryptedNickname []byte `json:"encryptedNickname,omitempty"`
}
// NewMembershipCRDT creates a new membership CRDT
func NewMembershipCRDT() *MembershipCRDT {
return &MembershipCRDT{
Added: make(map[string]*MemberEntry),
Removed: make(map[string]time.Time),
}
}
// Add adds a member to the conversation
func (m *MembershipCRDT) Add(accountID [32]byte, role string, addedBy [32]byte) string {
m.mu.Lock()
defer m.mu.Unlock()
// Generate unique tag
h := sha256.New()
h.Write(accountID[:])
binary.Write(h, binary.BigEndian, time.Now().UnixNano())
tag := string(h.Sum(nil)[:16])
m.Added[tag] = &MemberEntry{
AccountID: accountID,
Role: role,
AddedAt: time.Now(),
AddedBy: addedBy,
}
return tag
}
// Remove removes a member from the conversation
func (m *MembershipCRDT) Remove(tag string) {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.Added[tag]; exists {
m.Removed[tag] = time.Now()
}
}
// GetMembers returns all active members
func (m *MembershipCRDT) GetMembers() []*MemberEntry {
m.mu.RLock()
defer m.mu.RUnlock()
var members []*MemberEntry
for tag, entry := range m.Added {
if _, removed := m.Removed[tag]; !removed {
members = append(members, entry)
}
}
return members
}
// IsMember checks if an account is a member
func (m *MembershipCRDT) IsMember(accountID [32]byte) bool {
m.mu.RLock()
defer m.mu.RUnlock()
for tag, entry := range m.Added {
if entry.AccountID == accountID {
if _, removed := m.Removed[tag]; !removed {
return true
}
}
}
return false
}
// GetMember returns a member entry by account ID
func (m *MembershipCRDT) GetMember(accountID [32]byte) (*MemberEntry, string, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for tag, entry := range m.Added {
if entry.AccountID == accountID {
if _, removed := m.Removed[tag]; !removed {
return entry, tag, nil
}
}
}
return nil, "", ErrMemberNotFound
}
// Merge merges another membership CRDT into this one
func (m *MembershipCRDT) Merge(other *MembershipCRDT) {
m.mu.Lock()
defer m.mu.Unlock()
other.mu.RLock()
defer other.mu.RUnlock()
// Merge added entries
for tag, entry := range other.Added {
if existing, exists := m.Added[tag]; !exists || entry.AddedAt.After(existing.AddedAt) {
m.Added[tag] = entry
}
}
// Merge removed entries
for tag, removedAt := range other.Removed {
if existing, exists := m.Removed[tag]; !exists || removedAt.After(existing) {
m.Removed[tag] = removedAt
}
}
}
// ReadMarkerCRDT tracks read markers using LWW registers
type ReadMarkerCRDT struct {
Markers map[string]*ReadMarker `json:"markers"` // accountID hex -> ReadMarker
mu sync.RWMutex
}
// ReadMarker represents a member's read position
type ReadMarker struct {
AccountID [32]byte `json:"accountId"`
LastReadID ids.ID `json:"lastReadId"`
LastReadTime time.Time `json:"lastReadTime"`
UpdatedAt time.Time `json:"updatedAt"`
}
// NewReadMarkerCRDT creates a new read marker CRDT
func NewReadMarkerCRDT() *ReadMarkerCRDT {
return &ReadMarkerCRDT{
Markers: make(map[string]*ReadMarker),
}
}
// Update updates a read marker (LWW semantics)
func (r *ReadMarkerCRDT) Update(accountID [32]byte, lastReadID ids.ID, lastReadTime time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
key := string(accountID[:])
now := time.Now()
if existing, exists := r.Markers[key]; exists {
if now.After(existing.UpdatedAt) {
existing.LastReadID = lastReadID
existing.LastReadTime = lastReadTime
existing.UpdatedAt = now
}
} else {
r.Markers[key] = &ReadMarker{
AccountID: accountID,
LastReadID: lastReadID,
LastReadTime: lastReadTime,
UpdatedAt: now,
}
}
}
// Get returns a read marker for an account
func (r *ReadMarkerCRDT) Get(accountID [32]byte) *ReadMarker {
r.mu.RLock()
defer r.mu.RUnlock()
key := string(accountID[:])
return r.Markers[key]
}
// Merge merges another read marker CRDT into this one
func (r *ReadMarkerCRDT) Merge(other *ReadMarkerCRDT) {
r.mu.Lock()
defer r.mu.Unlock()
other.mu.RLock()
defer other.mu.RUnlock()
for key, marker := range other.Markers {
if existing, exists := r.Markers[key]; !exists || marker.UpdatedAt.After(existing.UpdatedAt) {
r.Markers[key] = marker
}
}
}
// RateLimiter provides rate limiting for message sending
type RateLimiter struct {
// Per-account counters
counters map[[32]byte]*RateCounter
// Configuration
maxPerMinute int
maxPerHour int
maxPerDay int
mu sync.RWMutex
}
// RateCounter tracks message counts for an account
type RateCounter struct {
AccountID [32]byte
MinuteCount int
HourCount int
DayCount int
MinuteReset time.Time
HourReset time.Time
DayReset time.Time
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter(maxPerMinute, maxPerHour, maxPerDay int) *RateLimiter {
return &RateLimiter{
counters: make(map[[32]byte]*RateCounter),
maxPerMinute: maxPerMinute,
maxPerHour: maxPerHour,
maxPerDay: maxPerDay,
}
}
// Check checks if an account can send a message
func (r *RateLimiter) Check(accountID [32]byte) error {
r.mu.Lock()
defer r.mu.Unlock()
counter, exists := r.counters[accountID]
if !exists {
counter = &RateCounter{
AccountID: accountID,
MinuteReset: time.Now().Add(time.Minute),
HourReset: time.Now().Add(time.Hour),
DayReset: time.Now().Add(24 * time.Hour),
}
r.counters[accountID] = counter
}
now := time.Now()
// Reset counters if needed
if now.After(counter.MinuteReset) {
counter.MinuteCount = 0
counter.MinuteReset = now.Add(time.Minute)
}
if now.After(counter.HourReset) {
counter.HourCount = 0
counter.HourReset = now.Add(time.Hour)
}
if now.After(counter.DayReset) {
counter.DayCount = 0
counter.DayReset = now.Add(24 * time.Hour)
}
// Check limits
if counter.MinuteCount >= r.maxPerMinute {
return ErrRateLimitExceeded
}
if counter.HourCount >= r.maxPerHour {
return ErrRateLimitExceeded
}
if counter.DayCount >= r.maxPerDay {
return ErrRateLimitExceeded
}
return nil
}
// Increment increments the counters for an account
func (r *RateLimiter) Increment(accountID [32]byte) {
r.mu.Lock()
defer r.mu.Unlock()
counter, exists := r.counters[accountID]
if !exists {
return
}
counter.MinuteCount++
counter.HourCount++
counter.DayCount++
}
// MessageStore manages encrypted messages with CRDT semantics
type MessageStore struct {
// Conversations
conversations map[ids.ID]*Conversation
// Rate limiter
rateLimiter *RateLimiter
// Encryption provider
encryptor Encryptor
mu sync.RWMutex
}
// Encryptor provides encryption/decryption operations
type Encryptor interface {
// Encrypt encrypts data for the given recipients
Encrypt(ctx context.Context, data []byte, recipients [][32]byte) ([]byte, error)
// Decrypt decrypts data using the given private key
Decrypt(ctx context.Context, ciphertext []byte, privateKey []byte) ([]byte, error)
// DeriveConversationKey derives a shared key for a conversation
DeriveConversationKey(conversationID ids.ID, members [][32]byte) ([]byte, error)
}
// NewMessageStore creates a new message store
func NewMessageStore(encryptor Encryptor) *MessageStore {
return &MessageStore{
conversations: make(map[ids.ID]*Conversation),
rateLimiter: NewRateLimiter(60, 1000, 10000),
encryptor: encryptor,
}
}
// CreateConversation creates a new conversation
func (s *MessageStore) CreateConversation(ctx context.Context, convType ConversationType, creatorID [32]byte, members [][32]byte) (*Conversation, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Generate conversation ID
h := sha256.New()
h.Write(creatorID[:])
for _, member := range members {
h.Write(member[:])
}
binary.Write(h, binary.BigEndian, time.Now().UnixNano())
convID := ids.ID(h.Sum(nil))
// Create membership CRDT
membership := NewMembershipCRDT()
// Add creator as admin
membership.Add(creatorID, "admin", creatorID)
// Add other members
for _, member := range members {
if member != creatorID {
membership.Add(member, "member", creatorID)
}
}
// Create read markers CRDT
readMarkers := NewReadMarkerCRDT()
now := time.Now()
conv := &Conversation{
ID: convID,
Type: convType,
CreatedAt: now,
UpdatedAt: now,
MembersCRDT: membership,
ReadMarkersCRDT: readMarkers,
KeyRotationEpoch: 0,
}
// Compute initial version
conv.Version = conv.Hash()
s.conversations[convID] = conv
return conv, nil
}
// GetConversation retrieves a conversation by ID
func (s *MessageStore) GetConversation(conversationID ids.ID) (*Conversation, error) {
s.mu.RLock()
defer s.mu.RUnlock()
conv, exists := s.conversations[conversationID]
if !exists {
return nil, ErrConversationNotFound
}
return conv, nil
}
// AddMember adds a member to a conversation
func (s *MessageStore) AddMember(ctx context.Context, conversationID ids.ID, accountID [32]byte, role string, addedBy [32]byte) error {
s.mu.Lock()
defer s.mu.Unlock()
conv, exists := s.conversations[conversationID]
if !exists {
return ErrConversationNotFound
}
// Verify adder is a member with admin role
adder, _, err := conv.MembersCRDT.GetMember(addedBy)
if err != nil {
return ErrNotMember
}
if adder.Role != "admin" && conv.Type == ConversationGroup {
return ErrNotMember
}
conv.MembersCRDT.Add(accountID, role, addedBy)
conv.UpdatedAt = time.Now()
conv.Version = conv.Hash()
return nil
}
// RemoveMember removes a member from a conversation
func (s *MessageStore) RemoveMember(ctx context.Context, conversationID ids.ID, accountID [32]byte, removedBy [32]byte) error {
s.mu.Lock()
defer s.mu.Unlock()
conv, exists := s.conversations[conversationID]
if !exists {
return ErrConversationNotFound
}
// Verify remover has permission
remover, _, err := conv.MembersCRDT.GetMember(removedBy)
if err != nil {
return ErrNotMember
}
if remover.Role != "admin" && removedBy != accountID {
return ErrNotMember
}
_, tag, err := conv.MembersCRDT.GetMember(accountID)
if err != nil {
return err
}
conv.MembersCRDT.Remove(tag)
conv.UpdatedAt = time.Now()
conv.Version = conv.Hash()
return nil
}
// UpdateReadMarker updates a read marker for an account
func (s *MessageStore) UpdateReadMarker(ctx context.Context, conversationID ids.ID, accountID [32]byte, lastReadID ids.ID) error {
s.mu.Lock()
defer s.mu.Unlock()
conv, exists := s.conversations[conversationID]
if !exists {
return ErrConversationNotFound
}
// Verify account is a member
if !conv.MembersCRDT.IsMember(accountID) {
return ErrNotMember
}
conv.ReadMarkersCRDT.Update(accountID, lastReadID, time.Now())
conv.UpdatedAt = time.Now()
return nil
}
// CheckRateLimit checks if an account can send a message
func (s *MessageStore) CheckRateLimit(accountID [32]byte) error {
return s.rateLimiter.Check(accountID)
}
// IncrementRateLimit increments the rate limit counters
func (s *MessageStore) IncrementRateLimit(accountID [32]byte) {
s.rateLimiter.Increment(accountID)
}
// SerializeConversation serializes a conversation to JSON
func SerializeConversation(conv *Conversation) ([]byte, error) {
return json.Marshal(conv)
}
// DeserializeConversation deserializes a conversation from JSON
func DeserializeConversation(data []byte) (*Conversation, error) {
var conv Conversation
if err := json.Unmarshal(data, &conv); err != nil {
return nil, err
}
return &conv, nil
}
// MergeConversations merges two conversation states
func MergeConversations(local, remote *Conversation) *Conversation {
// Use the newer version as base
var result *Conversation
if remote.UpdatedAt.After(local.UpdatedAt) {
result = remote
result.MembersCRDT.Merge(local.MembersCRDT)
result.ReadMarkersCRDT.Merge(local.ReadMarkersCRDT)
} else {
result = local
result.MembersCRDT.Merge(remote.MembersCRDT)
result.ReadMarkersCRDT.Merge(remote.ReadMarkersCRDT)
}
result.Version = result.Hash()
return result
}