Files
b22082b874 feat(store): per-tenant PQ-encrypted replicate streaming HA + PITR (Pillar 2) (#38)
Supersedes the auto-closed #36 (its base branch was deleted when #35 merged).
Gives each per-tenant SQLite DB continuous WAL streaming to durable replica
storage + restore-if-absent, so a pod dying restores each tenant DB from its
replica — and the replica is encrypted with the SAME per-tenant age key as
#35's whole-file path.

store/encreplica — a replicate.ReplicaClient that age-encrypts (ML-KEM-768 +
X25519) every LTX segment BEFORE it touches storage and decrypts on read. This
is the SOLE at-rest boundary for the replica stream. Encryption lives in the
client (after the plaintext LTX header is read), which is both the working path
and the correct single boundary — replicate v0.8.0's built-in
Replica.AgeRecipients encrypts before the file/s3 client peeks the header and
is unusable. Self-describing framing [8B plaintext len][8B ts][age ciphertext]
lets LTXFiles report accurate FileInfo (restore needs Size >= HeaderSize + a
resumable reader) without leaking any tenant bytes. Backend is a pluggable
Blobs store (LocalBlobs now; S3/vfs in prod — encryption identical per backend).

store/replicator — Open/Sync/Close streaming + Restore. Restore has a SQLite
safety gate: the reconstructed file must pass the magic check (defense in depth
beyond the client's per-tenant age-decrypt) or it is removed and rejected.

Addresses Red review of the prior HA PR:
- C1 (CRITICAL, proven): the replica was plaintext (the encrypting client only
  existed in comments). NOW REAL + WIRED + TESTED —
  TestReplicaBytesAreEncrypted_NoPlaintextMarker runs Red's exact PoC and the
  marker never appears in the clear; every LTX blob is age ciphertext.
- H1b: Restore now validates the restored SQLite before returning success.

Tests (CGO_ENABLED=0 go test ./store/encreplica/ ./store/replicator/):
no-plaintext-marker, write->replicate->wipe->restore->read, cross-tenant restore
FAILS, no-replica gate, two-cycle failover, LocalBlobs + New() validation.

Note: replicate transitively registers modernc.org/sqlite, which collides with
hanzoai/sqlite's mattn driver under CGO — the canonical pure-Go build
(CGO_ENABLED=0) resolves both to one registration.

Staged next (own PR): wire the replicator into MultiTenantStore hydrate
(restore-if-absent) / Checkpoint (Sync) / evict (Close) — H1a — handling the
fresh-tenant empty-file open and WAL-checkpoint coordination; + single-writer
lease/fencing (H1c). Not rushed into the data layer.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-04 14:20:21 -07:00

74 lines
1.9 KiB
Go

package encreplica_test
import (
"context"
"errors"
"os"
"testing"
"github.com/hanzoai/base/store/encreplica"
"github.com/luxfi/age"
)
func TestLocalBlobs_PutGetListDelete(t *testing.T) {
ctx := context.Background()
b := encreplica.NewLocalBlobs(t.TempDir())
if err := b.Put(ctx, "ltx/0/a", []byte("one")); err != nil {
t.Fatal(err)
}
if err := b.Put(ctx, "ltx/0/b", []byte("two")); err != nil {
t.Fatal(err)
}
got, err := b.Get(ctx, "ltx/0/a")
if err != nil || string(got) != "one" {
t.Fatalf("get: %q %v", got, err)
}
keys, err := b.List(ctx, "ltx/0/")
if err != nil {
t.Fatal(err)
}
if len(keys) != 2 {
t.Fatalf("list: want 2, got %d (%v)", len(keys), keys)
}
if err := b.Delete(ctx, "ltx/0/a"); err != nil {
t.Fatal(err)
}
if _, err := b.Get(ctx, "ltx/0/a"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("deleted key must be ErrNotExist, got %v", err)
}
}
func TestLocalBlobs_MissingIsErrNotExistAndEmptyList(t *testing.T) {
ctx := context.Background()
b := encreplica.NewLocalBlobs(t.TempDir())
if _, err := b.Get(ctx, "ltx/0/missing"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing key must be ErrNotExist, got %v", err)
}
keys, err := b.List(ctx, "ltx/9/")
if err != nil {
t.Fatalf("list of missing prefix must not error: %v", err)
}
if len(keys) != 0 {
t.Fatalf("list of missing prefix must be empty, got %v", keys)
}
}
func TestNew_RequiresBackendAndKey(t *testing.T) {
id, err := age.GenerateHybridIdentity()
if err != nil {
t.Fatal(err)
}
if _, err := encreplica.New(nil, id.Recipient(), id); err == nil {
t.Fatal("New must require a backend")
}
if _, err := encreplica.New(encreplica.NewLocalBlobs(t.TempDir()), nil, id); err == nil {
t.Fatal("New must require a recipient")
}
if _, err := encreplica.New(encreplica.NewLocalBlobs(t.TempDir()), id.Recipient(), nil); err == nil {
t.Fatal("New must require an identity")
}
}