mirror of
https://github.com/luxfi/database.git
synced 2026-10-30 15:01:18 +00:00
Replicates a zapdb to S3 via hanzoai/vfs with no keyspace scan in steady state and post-quantum encryption out of the box. - CDC incrementals via db.Subscribe (the WAL tail), not Backup(since)'s O(total) scan — a 100-key delta out of 200k keys: 2.76us/19KB vs 20ms/1GB (~7000x). - Physical SST-copy snapshots (sparse, hole-aware): 134ms/25MB vs logical 158ms/1.25GB/2.2M allocs. Hydrated before Badger opens; incrementals replay on top. - Streaming codec: Backup -> zstd -> age into a pooled buffer (peak mem = compressed). - Post-quantum (ML-KEM-768 + X25519 via luxfi/age v1.5.0), client-side before S3. Out of the box via REPLICATE_AGE_KEY_FILE (generate+persist, recipient derived). - Per-DB streams (REPLICATE_AUTO_ON_OPEN) for subprocess VM plugins; backer/restorer split (REPLICATE_BACKUP / REPLICATE_SOURCE_ORDINAL) for multi-node. - Public imports only (hanzoai/vfs v0.4.1, no replace). Verified live on an EVM C-chain end-to-end incl. disaster-recovery exact-match. Canonical KV schema (zapdb/zapdb.zap) + zero-copy codec (zapkv.go). Renames engine alias badger->zdb. Co-authored-by: Hanzo Dev <[email protected]>
99 lines
3.4 KiB
Go
99 lines
3.4 KiB
Go
// Copyright (C) 2020-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package zapdb
|
|
|
|
// Post-quantum encryption keys for replication, out of the box.
|
|
//
|
|
// Backups are age-encrypted client-side BEFORE they reach S3, so the hanzo-s3
|
|
// (MinIO-based) server never sees plaintext — its own server-side encryption is
|
|
// classical and trusts the server, which is exactly what we don't. The default
|
|
// here is the post-quantum hybrid (ML-KEM-768 + X25519): a single key file, with
|
|
// the recipient derived from the identity, gives PQ encrypt+decrypt with no
|
|
// out-of-band key wrangling. PQ costs ~1.6 KB and ~0.1 ms per object — free.
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/luxfi/age"
|
|
log "github.com/luxfi/log"
|
|
)
|
|
|
|
// resolveReplicationIdentity returns the age identity used to DECRYPT backups:
|
|
// REPLICATE_AGE_IDENTITY if set, otherwise generated-or-loaded from
|
|
// REPLICATE_AGE_KEY_FILE (the out-of-box PQ path). nil → plaintext. Shared by
|
|
// the replicator and the pre-open physical hydrate so both decrypt with the same
|
|
// key — a mismatch here is what made physical restore fail without an identity.
|
|
func resolveReplicationIdentity() age.Identity {
|
|
s := os.Getenv("REPLICATE_AGE_IDENTITY")
|
|
if s == "" {
|
|
if kf := os.Getenv("REPLICATE_AGE_KEY_FILE"); kf != "" {
|
|
k, err := loadOrCreatePQKey(kf)
|
|
if err != nil {
|
|
log.Warn(fmt.Sprintf("[zapdb] replicate: PQ key file %s: %v", kf, err))
|
|
return nil
|
|
}
|
|
s = k
|
|
}
|
|
}
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
ids, err := age.ParseIdentities(strings.NewReader(s))
|
|
if err != nil || len(ids) == 0 {
|
|
log.Warn(fmt.Sprintf("[zapdb] replicate: parse age identity: %v", err))
|
|
return nil
|
|
}
|
|
return ids[0]
|
|
}
|
|
|
|
// recipientFor returns the public recipient that matches an age identity, so one
|
|
// secret configures both encrypt and decrypt. nil for identity types without a
|
|
// derivable recipient (e.g. scrypt/passphrase).
|
|
func recipientFor(id age.Identity) age.Recipient {
|
|
switch i := id.(type) {
|
|
case *age.HybridIdentity:
|
|
return i.Recipient()
|
|
case *age.XWingIdentity:
|
|
return i.Recipient()
|
|
case *age.X25519Identity:
|
|
return i.Recipient()
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// loadOrCreatePQKey returns the age identity string at path, generating a fresh
|
|
// post-quantum (ML-KEM-768 + X25519) key there on first use. The identity is
|
|
// written 0600 and the public recipient to "<path>.pub" for sharing. Keep this
|
|
// file OUTSIDE the replicated DB dir and back it up: it is the only thing that
|
|
// can decrypt the backups (lose it and the snapshots are unrecoverable).
|
|
func loadOrCreatePQKey(path string) (string, error) {
|
|
if b, err := os.ReadFile(path); err == nil {
|
|
s := strings.TrimSpace(string(b))
|
|
if s == "" {
|
|
return "", fmt.Errorf("key file is empty")
|
|
}
|
|
return s, nil
|
|
} else if !os.IsNotExist(err) {
|
|
return "", err
|
|
}
|
|
|
|
id, err := age.GeneratePQIdentity(age.PQKemHPKEMLKEM768X25519)
|
|
if err != nil {
|
|
return "", fmt.Errorf("generate PQ identity: %w", err)
|
|
}
|
|
secret := fmt.Sprint(id) // AGE-SECRET-KEY-PQ-1...
|
|
if err := os.WriteFile(path, []byte(secret+"\n"), 0o600); err != nil {
|
|
return "", fmt.Errorf("write key file: %w", err)
|
|
}
|
|
if r := recipientFor(id); r != nil {
|
|
rs := fmt.Sprint(r) // age1pq1...
|
|
_ = os.WriteFile(path+".pub", []byte(rs+"\n"), 0o644)
|
|
log.Info(fmt.Sprintf("[zapdb] replicate: generated post-quantum key %s (recipient %s) — BACK THIS UP", path, rs))
|
|
}
|
|
return secret, nil
|
|
}
|