mirror of
https://github.com/hanzoai/pubsub.git
synced 2026-10-30 15:06:26 +00:00
318 lines
7.7 KiB
Go
318 lines
7.7 KiB
Go
// Copyright 2026 Hanzo AI Inc. All rights reserved.
|
|
// Licensed under the Apache License, Version 2.0.
|
|
|
|
// Package store provides a zapdb-backed key-value store for pubsub metadata,
|
|
// consensus state, and PQ-safe encrypted backup/restore.
|
|
//
|
|
// Storage stack:
|
|
//
|
|
// PubSub streams → zapdb (AES-256 at rest) → Backup → age(ML-KEM-768+X25519) → S3/disk
|
|
//
|
|
// All data is encrypted at rest via AES-256 (zapdb EncryptionKey).
|
|
// Backups use luxfi/age HybridRecipient (ML-KEM-768 + X25519) for PQ-safe encryption.
|
|
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/luxfi/age"
|
|
badger "github.com/luxfi/zapdb"
|
|
)
|
|
|
|
// Store wraps zapdb for pubsub metadata, consensus state, and PQ-safe backup.
|
|
type Store struct {
|
|
db *badger.DB
|
|
path string
|
|
|
|
// PQ encryption for backups (nil = plaintext backups).
|
|
ageRecipient age.Recipient // public key: encrypt backups
|
|
ageIdentity age.Identity // private key: decrypt restores
|
|
}
|
|
|
|
// Config configures the zapdb store.
|
|
type Config struct {
|
|
// Dir is the directory for zapdb data files.
|
|
Dir string
|
|
|
|
// EncryptionKey enables AES encryption at rest (must be 16, 24, or 32 bytes).
|
|
// Required for non-InMemory stores unless RequireEncryption is false.
|
|
EncryptionKey []byte
|
|
|
|
// RequireEncryption fails Open if EncryptionKey is empty on a disk store.
|
|
// Default true — set false only for dev/test.
|
|
RequireEncryption *bool
|
|
|
|
// SyncWrites ensures durability on each write (slower but safer).
|
|
SyncWrites bool
|
|
|
|
// InMemory runs zapdb without disk persistence (for testing).
|
|
InMemory bool
|
|
|
|
// PQ-safe backup encryption using luxfi/age ML-KEM-768 + X25519 (X-Wing).
|
|
// Set AgeRecipient for encrypting backups.
|
|
// Set AgeIdentity for decrypting restores.
|
|
// Both nil = plaintext backups.
|
|
AgeRecipient age.Recipient
|
|
AgeIdentity age.Identity
|
|
}
|
|
|
|
// Open creates or opens a zapdb store.
|
|
func Open(cfg Config) (*Store, error) {
|
|
if cfg.Dir == "" && !cfg.InMemory {
|
|
return nil, fmt.Errorf("zapstore: dir required when not in-memory")
|
|
}
|
|
|
|
requireEnc := cfg.RequireEncryption == nil || *cfg.RequireEncryption
|
|
if !cfg.InMemory && requireEnc && len(cfg.EncryptionKey) == 0 {
|
|
return nil, fmt.Errorf("zapstore: encryption key required for disk store (set RequireEncryption=false to disable)")
|
|
}
|
|
if len(cfg.EncryptionKey) > 0 {
|
|
switch len(cfg.EncryptionKey) {
|
|
case 16, 24, 32:
|
|
default:
|
|
return nil, fmt.Errorf("zapstore: encryption key must be 16, 24, or 32 bytes, got %d", len(cfg.EncryptionKey))
|
|
}
|
|
}
|
|
|
|
if !cfg.InMemory {
|
|
if err := os.MkdirAll(cfg.Dir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("zapstore: mkdir %s: %w", cfg.Dir, err)
|
|
}
|
|
}
|
|
|
|
opts := badger.DefaultOptions(cfg.Dir)
|
|
opts.SyncWrites = cfg.SyncWrites
|
|
opts.NumVersionsToKeep = 1
|
|
opts.Logger = nil
|
|
|
|
if cfg.InMemory {
|
|
opts.InMemory = true
|
|
opts.Dir = ""
|
|
opts.ValueDir = ""
|
|
}
|
|
|
|
if len(cfg.EncryptionKey) > 0 {
|
|
opts.EncryptionKey = cfg.EncryptionKey
|
|
opts.EncryptionKeyRotationDuration = 24 * time.Hour
|
|
}
|
|
|
|
db, err := badger.Open(opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("zapstore: open: %w", err)
|
|
}
|
|
|
|
return &Store{
|
|
db: db,
|
|
path: cfg.Dir,
|
|
ageRecipient: cfg.AgeRecipient,
|
|
ageIdentity: cfg.AgeIdentity,
|
|
}, nil
|
|
}
|
|
|
|
// Close closes the store.
|
|
func (s *Store) Close() error {
|
|
return s.db.Close()
|
|
}
|
|
|
|
// Path returns the store directory.
|
|
func (s *Store) Path() string {
|
|
return s.path
|
|
}
|
|
|
|
// DB returns the underlying zapdb instance for advanced operations.
|
|
func (s *Store) DB() *badger.DB {
|
|
return s.db
|
|
}
|
|
|
|
// --- Key-Value Operations ---
|
|
|
|
func (s *Store) Get(key []byte) ([]byte, error) {
|
|
var val []byte
|
|
err := s.db.View(func(txn *badger.Txn) error {
|
|
item, err := txn.Get(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
val, err = item.ValueCopy(nil)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return val, nil
|
|
}
|
|
|
|
func (s *Store) Set(key, value []byte) error {
|
|
return s.db.Update(func(txn *badger.Txn) error {
|
|
return txn.Set(key, value)
|
|
})
|
|
}
|
|
|
|
func (s *Store) SetWithTTL(key, value []byte, ttl time.Duration) error {
|
|
return s.db.Update(func(txn *badger.Txn) error {
|
|
e := badger.NewEntry(key, value).WithTTL(ttl)
|
|
return txn.SetEntry(e)
|
|
})
|
|
}
|
|
|
|
func (s *Store) Delete(key []byte) error {
|
|
return s.db.Update(func(txn *badger.Txn) error {
|
|
return txn.Delete(key)
|
|
})
|
|
}
|
|
|
|
// --- JSON Convenience ---
|
|
|
|
func (s *Store) GetJSON(key []byte, v any) error {
|
|
data, err := s.Get(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(data, v)
|
|
}
|
|
|
|
func (s *Store) SetJSON(key []byte, v any) error {
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.Set(key, data)
|
|
}
|
|
|
|
// --- Prefix Scan ---
|
|
|
|
func (s *Store) Scan(prefix []byte, fn func(key, value []byte) bool) error {
|
|
return s.db.View(func(txn *badger.Txn) error {
|
|
opts := badger.DefaultIteratorOptions
|
|
opts.Prefix = prefix
|
|
it := txn.NewIterator(opts)
|
|
defer it.Close()
|
|
|
|
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
|
|
item := it.Item()
|
|
k := item.KeyCopy(nil)
|
|
err := item.Value(func(v []byte) error {
|
|
if !fn(k, v) {
|
|
return fmt.Errorf("stop")
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
if err.Error() == "stop" {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// --- Batch Operations ---
|
|
|
|
func (s *Store) WriteBatch() *badger.WriteBatch {
|
|
return s.db.NewWriteBatch()
|
|
}
|
|
|
|
// --- PQ-Safe Backup & Restore ---
|
|
//
|
|
// Backups are encrypted with luxfi/age ML-KEM-768+X25519 (X-Wing hybrid)
|
|
// when AgeRecipient is configured. This provides post-quantum safe encryption
|
|
// for backup data at rest (S3, disk, etc).
|
|
|
|
// Backup writes an incremental backup to the given directory.
|
|
// If AgeRecipient is set, the backup is PQ-encrypted with age.
|
|
// Pass 0 for since to get a full backup.
|
|
func (s *Store) Backup(dir string, since uint64) (uint64, error) {
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return 0, fmt.Errorf("zapstore: backup mkdir: %w", err)
|
|
}
|
|
|
|
ext := ".zap"
|
|
if s.ageRecipient != nil {
|
|
ext = ".zap.age"
|
|
}
|
|
name := fmt.Sprintf("pubsub-%d%s", time.Now().Unix(), ext)
|
|
path := filepath.Join(dir, name)
|
|
|
|
// Backup to buffer first
|
|
var buf bytes.Buffer
|
|
version, err := s.db.Backup(&buf, since)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("zapstore: backup: %w", err)
|
|
}
|
|
if buf.Len() == 0 {
|
|
return version, nil
|
|
}
|
|
|
|
// Encrypt with age if recipient is configured (PQ-safe: ML-KEM-768+X25519)
|
|
var body io.Reader = &buf
|
|
if s.ageRecipient != nil {
|
|
encrypted, err := ageEncrypt(&buf, s.ageRecipient)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("zapstore: age encrypt: %w", err)
|
|
}
|
|
body = encrypted
|
|
}
|
|
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("zapstore: backup create: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
if _, err := io.Copy(f, body); err != nil {
|
|
return 0, fmt.Errorf("zapstore: backup write: %w", err)
|
|
}
|
|
|
|
return version, nil
|
|
}
|
|
|
|
// Restore loads a backup from a file.
|
|
// If AgeIdentity is set, the backup is decrypted with age first.
|
|
func (s *Store) Restore(path string) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return fmt.Errorf("zapstore: restore open: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
var reader io.Reader = f
|
|
if s.ageIdentity != nil && filepath.Ext(path) == ".age" {
|
|
dec, err := age.Decrypt(f, s.ageIdentity)
|
|
if err != nil {
|
|
return fmt.Errorf("zapstore: age decrypt: %w", err)
|
|
}
|
|
reader = dec
|
|
}
|
|
|
|
return s.db.Load(reader, 256)
|
|
}
|
|
|
|
func ageEncrypt(plaintext io.Reader, recipient age.Recipient) (*bytes.Buffer, error) {
|
|
var out bytes.Buffer
|
|
w, err := age.Encrypt(&out, recipient)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := io.Copy(w, plaintext); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// --- Maintenance ---
|
|
|
|
func (s *Store) RunGC(discardRatio float64) error {
|
|
return s.db.RunValueLogGC(discardRatio)
|
|
}
|