Files
sqlite/threshold.go
zooqueen 467c7cd910 feat: dual-backend driver (cgo SQLCipher / !cgo modernc), reopen-safe encryption
Register the "sqlite" database/sql driver under BOTH build tags with one API:

  - //go:build cgo  -> mattn/go-sqlite3 linked against libsqlcipher. Encryption
    uses SQLCipher's NATIVE URI key param (file:PATH?...&key=x'HEX'), applied at
    sqlite3_open_v2 time. This is the ONLY mechanism that reopens an encrypted
    DB: mattn runs busy_timeout/journal_mode/foreign_keys PRAGMAs before any
    ConnectHook, so a ConnectHook can create but never reopen a SQLCipher file
    ("file is not a database"). mainline mattn has no `sqlcipher` tag and silently
    ignores `_key=`/`_pragma=key` -> the old approach shipped plaintext.
  - //go:build !cgo -> modernc.org/sqlite (pure Go); registers "sqlite" via its
    own init. Cannot encrypt: a keyed Open returns ErrEncryptionUnavailable and
    writes nothing (fail-secure, never silent plaintext).

Decomplected: HKDF CEK derivation (cek.go) and threshold attestation
(threshold.go) are pure Go and now tag-neutral; only driver registration + DSN
construction differ by tag (driver_cgo.go / driver_nocgo.go). DSN() emits the
backend-correct params (modernc `_pragma=` form vs mattn `_busy_timeout=` form),
fixing the latent bug where modernc silently ignored mattn-style pragma params.

OpenDB(path, key) returns a *sql.DB for xorm.NewEngineWithDB callers (IAM).

TestEncryptionProof is the anti-silent-plaintext CI gate: under cgo it asserts
real ciphertext on disk + keyed reopen + wrong-key rejection, so a build that
forgets to link libsqlcipher FAILS instead of shipping plaintext.

Build (prod): CGO_ENABLED=1 -tags "libsqlite3 sqlite_fts5"
  CGO_CFLAGS="-DSQLITE_HAS_CODEC -DSQLITE_USE_URI=1 -I<sqlcipher>/include/sqlcipher"
  CGO_LDFLAGS="-lsqlcipher"
2026-06-22 22:55:33 -07:00

197 lines
4.8 KiB
Go

// Threshold write attestation for multi-party SQLite.
//
// Each party runs a node with a full replica. Write operations require
// t-of-n parties to sign the write before it's committed. Reads are local.
//
// Use case: MPC wallet shard storage, DEX trade approvals, multi-sig
// transaction authorization in trading platforms.
package sqlite
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"sync"
"time"
)
var (
ErrThresholdNotMet = errors.New("sqlite: threshold attestations not met")
ErrDuplicateSigner = errors.New("sqlite: duplicate signer")
ErrInvalidSig = errors.New("sqlite: invalid attestation signature")
ErrSessionExpired = errors.New("sqlite: attestation session expired")
)
// WriteProposal is a proposed write that needs t-of-n attestations.
type WriteProposal struct {
ID [32]byte // SHA-256 of the SQL + params
SQL string
Params []any
Proposer string // node ID of proposer
CreatedAt time.Time
ExpiresAt time.Time
mu sync.Mutex
attestations map[string][]byte // nodeID → Ed25519 signature
}
// ThresholdManager coordinates multi-party write attestation.
type ThresholdManager struct {
threshold int
parties int
nodeID string
signingKey ed25519.PrivateKey
peerKeys map[string]ed25519.PublicKey // nodeID → public key
mu sync.Mutex
pending map[[32]byte]*WriteProposal
commitFn func(sql string, params []any) error
}
// NewThresholdManager creates a threshold write coordinator.
func NewThresholdManager(threshold, parties int, nodeID string, signingKey ed25519.PrivateKey) *ThresholdManager {
return &ThresholdManager{
threshold: threshold,
parties: parties,
nodeID: nodeID,
signingKey: signingKey,
peerKeys: make(map[string]ed25519.PublicKey),
pending: make(map[[32]byte]*WriteProposal),
}
}
// RegisterPeer adds a peer's public key for attestation verification.
func (tm *ThresholdManager) RegisterPeer(nodeID string, pubKey ed25519.PublicKey) {
tm.mu.Lock()
defer tm.mu.Unlock()
tm.peerKeys[nodeID] = pubKey
}
// SetCommitFunc sets the function called when threshold is met.
func (tm *ThresholdManager) SetCommitFunc(fn func(string, []any) error) {
tm.commitFn = fn
}
// Propose creates a new write proposal. Returns the proposal ID.
func (tm *ThresholdManager) Propose(sql string, params []any) ([32]byte, error) {
id := hashProposal(sql, params)
proposal := &WriteProposal{
ID: id,
SQL: sql,
Params: params,
Proposer: tm.nodeID,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(30 * time.Second),
attestations: make(map[string][]byte),
}
// Self-attest
sig := ed25519.Sign(tm.signingKey, id[:])
proposal.attestations[tm.nodeID] = sig
tm.mu.Lock()
tm.pending[id] = proposal
tm.mu.Unlock()
// Check if self-attestation meets threshold (1-of-1)
if len(proposal.attestations) >= tm.threshold {
return id, tm.tryCommit(id)
}
return id, nil
}
// Attest adds a peer's attestation to a pending proposal.
func (tm *ThresholdManager) Attest(proposalID [32]byte, nodeID string, signature []byte) error {
tm.mu.Lock()
proposal, ok := tm.pending[proposalID]
tm.mu.Unlock()
if !ok {
return fmt.Errorf("sqlite: proposal %x not found", proposalID[:8])
}
if time.Now().After(proposal.ExpiresAt) {
return ErrSessionExpired
}
// Verify signature
pubKey, ok := tm.peerKeys[nodeID]
if !ok {
return fmt.Errorf("sqlite: unknown peer %s", nodeID)
}
if !ed25519.Verify(pubKey, proposalID[:], signature) {
return ErrInvalidSig
}
proposal.mu.Lock()
if _, dup := proposal.attestations[nodeID]; dup {
proposal.mu.Unlock()
return ErrDuplicateSigner
}
proposal.attestations[nodeID] = signature
count := len(proposal.attestations)
proposal.mu.Unlock()
// Check threshold
if count >= tm.threshold {
return tm.tryCommit(proposalID)
}
return nil
}
// tryCommit executes the write if threshold is met.
func (tm *ThresholdManager) tryCommit(id [32]byte) error {
tm.mu.Lock()
proposal, ok := tm.pending[id]
if ok {
delete(tm.pending, id)
}
tm.mu.Unlock()
if !ok || tm.commitFn == nil {
return nil
}
return tm.commitFn(proposal.SQL, proposal.Params)
}
// Pending returns the number of pending proposals.
func (tm *ThresholdManager) Pending() int {
tm.mu.Lock()
defer tm.mu.Unlock()
return len(tm.pending)
}
// CleanExpired removes expired proposals.
func (tm *ThresholdManager) CleanExpired() int {
tm.mu.Lock()
defer tm.mu.Unlock()
now := time.Now()
cleaned := 0
for id, p := range tm.pending {
if now.After(p.ExpiresAt) {
delete(tm.pending, id)
cleaned++
}
}
return cleaned
}
func hashProposal(sql string, params []any) [32]byte {
h := sha256.New()
h.Write([]byte(sql))
for _, p := range params {
b := []byte(fmt.Sprintf("%v", p))
binary.Write(h, binary.LittleEndian, uint32(len(b)))
h.Write(b)
}
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}