From b4039dc14842167b497ea847ab72bb8a32a2e286 Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 20 Nov 2025 17:56:40 -0600 Subject: [PATCH] feat: implement ratchet management with persistence and rotation --- pkg/destination/destination.go | 217 ++++++++++++++++++++++++++++++++- 1 file changed, 212 insertions(+), 5 deletions(-) diff --git a/pkg/destination/destination.go b/pkg/destination/destination.go index a3d963b..ae78a28 100644 --- a/pkg/destination/destination.go +++ b/pkg/destination/destination.go @@ -1,16 +1,22 @@ package destination import ( + "crypto/rand" "crypto/sha256" "errors" "fmt" + "io" + "os" "sync" + "time" "github.com/Sudo-Ivan/reticulum-go/pkg/announce" "github.com/Sudo-Ivan/reticulum-go/pkg/common" "github.com/Sudo-Ivan/reticulum-go/pkg/debug" "github.com/Sudo-Ivan/reticulum-go/pkg/identity" "github.com/Sudo-Ivan/reticulum-go/pkg/transport" + "github.com/vmihailenco/msgpack/v5" + "golang.org/x/crypto/curve25519" ) const ( @@ -65,11 +71,15 @@ type Destination struct { proofCallback ProofRequestedCallback linkCallback LinkEstablishedCallback - ratchetsEnabled bool - ratchetPath string - ratchetCount int - ratchetInterval int - enforceRatchets bool + ratchetsEnabled bool + ratchetPath string + ratchetCount int + ratchetInterval int + enforceRatchets bool + latestRatchetTime time.Time + latestRatchetID []byte + ratchets [][]byte + ratchetFileLock sync.Mutex defaultAppData []byte mutex sync.RWMutex @@ -282,8 +292,27 @@ func (d *Destination) EnableRatchets(path string) bool { d.mutex.Lock() defer d.mutex.Unlock() + if path == "" { + debug.Log(debug.DEBUG_ERROR, "No ratchet file path specified") + return false + } + d.ratchetsEnabled = true d.ratchetPath = path + d.latestRatchetTime = time.Time{} // Zero time to force rotation + + // Load or initialize ratchets + if err := d.reloadRatchets(); err != nil { + debug.Log(debug.DEBUG_ERROR, "Failed to load ratchets", "error", err) + // Initialize empty ratchet list + d.ratchets = make([][]byte, 0) + if err := d.persistRatchets(); err != nil { + debug.Log(debug.DEBUG_ERROR, "Failed to create initial ratchet file", "error", err) + return false + } + } + + debug.Log(debug.DEBUG_INFO, "Ratchets enabled", "path", path) return true } @@ -452,3 +481,181 @@ func (d *Destination) GetHash() []byte { } return d.hashValue } + +func (d *Destination) persistRatchets() error { + d.ratchetFileLock.Lock() + defer d.ratchetFileLock.Unlock() + + if !d.ratchetsEnabled || d.ratchetPath == "" { + return errors.New("ratchets not enabled or no path specified") + } + + debug.Log(debug.DEBUG_PACKETS, "Persisting ratchets", "count", len(d.ratchets), "path", d.ratchetPath) + + // Pack ratchets using msgpack + packedRatchets, err := msgpack.Marshal(d.ratchets) + if err != nil { + return fmt.Errorf("failed to pack ratchets: %w", err) + } + + // Sign the packed ratchets + signature, err := d.Sign(packedRatchets) + if err != nil { + return fmt.Errorf("failed to sign ratchets: %w", err) + } + + // Create structure + persistedData := map[string][]byte{ + "signature": signature, + "ratchets": packedRatchets, + } + + // Pack the entire structure + finalData, err := msgpack.Marshal(persistedData) + if err != nil { + return fmt.Errorf("failed to pack ratchet data: %w", err) + } + + // Write to temporary file first, then rename (atomic operation) + tempPath := d.ratchetPath + ".tmp" + file, err := os.Create(tempPath) // #nosec G304 + if err != nil { + return fmt.Errorf("failed to create temp ratchet file: %w", err) + } + + if _, err := file.Write(finalData); err != nil { + file.Close() + os.Remove(tempPath) + return fmt.Errorf("failed to write ratchet data: %w", err) + } + file.Close() + + // Remove old file if exists + if _, err := os.Stat(d.ratchetPath); err == nil { + os.Remove(d.ratchetPath) + } + + // Atomic rename + if err := os.Rename(tempPath, d.ratchetPath); err != nil { + os.Remove(tempPath) + return fmt.Errorf("failed to rename ratchet file: %w", err) + } + + debug.Log(debug.DEBUG_PACKETS, "Ratchets persisted successfully") + return nil +} + +func (d *Destination) reloadRatchets() error { + d.ratchetFileLock.Lock() + defer d.ratchetFileLock.Unlock() + + if _, err := os.Stat(d.ratchetPath); os.IsNotExist(err) { + debug.Log(debug.DEBUG_INFO, "No existing ratchet data found, initializing new ratchet file") + d.ratchets = make([][]byte, 0) + return nil + } + + file, err := os.Open(d.ratchetPath) // #nosec G304 + if err != nil { + return fmt.Errorf("failed to open ratchet file: %w", err) + } + defer file.Close() + + // Read all data + fileData, err := io.ReadAll(file) + if err != nil { + return fmt.Errorf("failed to read ratchet file: %w", err) + } + + // Unpack outer structure + var persistedData map[string][]byte + if err := msgpack.Unmarshal(fileData, &persistedData); err != nil { + return fmt.Errorf("failed to unpack ratchet data: %w", err) + } + + signature, hasSignature := persistedData["signature"] + packedRatchets, hasRatchets := persistedData["ratchets"] + + if !hasSignature || !hasRatchets { + return fmt.Errorf("invalid ratchet file format") + } + + // Verify signature + if !d.identity.Verify(packedRatchets, signature) { + return fmt.Errorf("invalid ratchet file signature") + } + + // Unpack ratchet list + if err := msgpack.Unmarshal(packedRatchets, &d.ratchets); err != nil { + return fmt.Errorf("failed to unpack ratchet list: %w", err) + } + + debug.Log(debug.DEBUG_INFO, "Ratchets reloaded successfully", "count", len(d.ratchets)) + return nil +} + +func (d *Destination) RotateRatchets() error { + d.mutex.Lock() + defer d.mutex.Unlock() + + if !d.ratchetsEnabled { + return errors.New("ratchets not enabled") + } + + now := time.Now() + if !d.latestRatchetTime.IsZero() && now.Before(d.latestRatchetTime.Add(time.Duration(d.ratchetInterval)*time.Second)) { + debug.Log(debug.DEBUG_TRACE, "Ratchet rotation interval not reached") + return nil + } + + debug.Log(debug.DEBUG_INFO, "Rotating ratchets", "destination", d.ExpandName()) + + // Generate new ratchet key (32 bytes for X25519 private key) + newRatchet := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, newRatchet); err != nil { + return fmt.Errorf("failed to generate new ratchet: %w", err) + } + + // Insert at beginning (most recent first) + d.ratchets = append([][]byte{newRatchet}, d.ratchets...) + d.latestRatchetTime = now + + // Get ratchet public key for ID + ratchetPub, err := curve25519.X25519(newRatchet, curve25519.Basepoint) + if err == nil { + d.latestRatchetID = identity.TruncatedHash(ratchetPub)[:identity.NAME_HASH_LENGTH/8] + } + + // Clean old ratchets + d.cleanRatchets() + + // Persist to disk + if err := d.persistRatchets(); err != nil { + debug.Log(debug.DEBUG_ERROR, "Failed to persist ratchets after rotation", "error", err) + return err + } + + debug.Log(debug.DEBUG_INFO, "Ratchet rotation completed", "total_ratchets", len(d.ratchets)) + return nil +} + +func (d *Destination) cleanRatchets() { + if len(d.ratchets) > d.ratchetCount { + debug.Log(debug.DEBUG_TRACE, "Cleaning old ratchets", "before", len(d.ratchets), "keeping", d.ratchetCount) + d.ratchets = d.ratchets[:d.ratchetCount] + } +} + +func (d *Destination) GetRatchets() [][]byte { + d.mutex.RLock() + defer d.mutex.RUnlock() + + if !d.ratchetsEnabled { + return nil + } + + // Return copy to prevent external modification + ratchetsCopy := make([][]byte, len(d.ratchets)) + copy(ratchetsCopy, d.ratchets) + return ratchetsCopy +}