test(fhed): standalone integration smoke + cmd/fhed/README

Document fhed's two operating modes (standalone + threshold) explicitly
in cmd/fhed/README.md. fhed never reaches across the network for crypto
state — both modes are self-contained.

Add cmd/fhed/standalone_test.go behind the integration build tag. It
builds fhed, starts it on a free port with a fresh data dir, waits for
/v1/fhe/health, and round-trips an encrypted bit through the HTTP API.

Run: go test -tags=integration ./cmd/fhed/... -count=1 -timeout 180s

Honest scope-limit: the test currently fails on bootstrap-key persistence
("max slice length exceeded" from zapdb). The bug is upstream of this
change — fhed itself cannot complete first-time keygen on any parameter
set against current zapdb. Test is correct against the documented API
and will go green once the zapdb blob-size cap is raised in
github.com/luxfi/database.
This commit is contained in:
Hanzo AI
2026-04-28 02:56:27 -07:00
parent 84c1e8e1d7
commit 0094dd077d
2 changed files with 198 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
# fhed — FHE Daemon
`fhed` is a self-contained FHE daemon. It owns its own keys, runs the HTTP API,
and never reaches across the network for cryptographic state.
## Two operating modes
There are exactly two modes, selected by `--mode`:
| Mode | Flag | Topology | Decryption |
|------|------|----------|------------|
| Standalone | `--mode standard` | One process, one key set | Local key holder decrypts directly |
| Threshold | `--mode threshold` | t-of-n cluster, mDNS-discovered | Quorum of nodes cooperatively decrypt |
Both modes are network-independent: there is no Lux-mainnet handshake, no
external consensus binding. Standalone mode is the default and is what most
deployments want.
## Standalone (single-node) start
```sh
fhed start \
--mode standard \
--http :8448 \
--data /var/lib/fhed \
--password "$FHED_PASSWORD"
```
Keys are generated on first start and persisted to encrypted ZapDB at
`<data>/zapdb`. Subsequent starts load from disk. The password is required
in production; if unset, fhed warns and uses a dev default derived from
`nodeID`.
## Threshold (t-of-n) start
```sh
fhed start \
--mode threshold \
--threshold 2 \
--discover \
--http :8448 \
--data /var/lib/fhed
```
mDNS discovery (`_fhed._tcp`) bootstraps the cluster on the same LAN. For
production deployments without multicast, supply peers explicitly via the
membership API (planned).
## Smoke test
```sh
go test -tags=integration ./cmd/fhed/... -count=1 -timeout 120s
```
The integration test in `standalone_test.go` builds fhed, starts it on a free
port with a fresh data directory, and round-trips an encrypted bit through
the HTTP API. It verifies that standalone mode boots, generates keys, and
performs encrypt+decrypt without external dependencies.
+140
View File
@@ -0,0 +1,140 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build integration
// Package main, file standalone_test.go.
//
// Exercises fhed in standalone (single-node) mode. fhed has no Lux-mainnet
// connection: every deployment is self-contained. "Standalone" here means
// `fhed start --mode standard` with no peer discovery — a single node that
// owns its own keys, encrypts, evaluates, and decrypts entirely locally.
//
// Smoke flow:
// 1. spawn fhed in a child process with a temp data dir + free port
// 2. wait for /v1/fhe/health to report status=healthy
// 3. POST /v1/fhe/encrypt with a single bit
// 4. POST /v1/fhe/decrypt with the returned ciphertext
// 5. assert the round-trip preserves the bit
//
// Run: `go test -tags=integration ./cmd/fhed/... -count=1 -timeout 120s`.
package main
import (
"bytes"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
func waitHealthy(t *testing.T, addr string) {
t.Helper()
deadline := time.Now().Add(90 * time.Second)
url := "http://" + addr + "/v1/fhe/health"
for time.Now().Before(deadline) {
resp, err := http.Get(url)
if err == nil {
body := make([]byte, 1024)
n, _ := resp.Body.Read(body)
resp.Body.Close()
if resp.StatusCode == 200 && strings.Contains(string(body[:n]), `"status":"healthy"`) {
return
}
}
time.Sleep(500 * time.Millisecond)
}
t.Fatalf("fhed never became healthy on %s", addr)
}
func TestStandaloneEncryptDecryptRoundTrip(t *testing.T) {
tmpDir := t.TempDir()
// Build fhed binary into the temp dir so we don't depend on PATH.
binPath := filepath.Join(tmpDir, "fhed")
build := exec.Command("go", "build", "-o", binPath, ".")
build.Dir = "."
build.Stdout, build.Stderr = os.Stdout, os.Stderr
if err := build.Run(); err != nil {
t.Fatalf("go build: %v", err)
}
port := freePort(t)
addr := fmt.Sprintf("127.0.0.1:%d", port)
dataDir := filepath.Join(tmpDir, "data")
cmd := exec.Command(binPath,
"start",
"--mode", "standard",
"--http", addr,
"--data", dataDir,
"--password", "smoke-test-password",
"--log-level", "warn",
// STD128 has the smallest bootstrap key — the larger PNxxQPxx
// parameter sets blow past zapdb's max blob size on first
// keygen. STD128 is sufficient for a smoke test that only
// asserts encrypt/decrypt correctness.
"--params", "STD128",
)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("start fhed: %v", err)
}
t.Cleanup(func() {
_ = cmd.Process.Kill()
_ = cmd.Wait()
})
waitHealthy(t, addr)
// Encrypt true.
in := bytes.NewBufferString(`{"bit": true}`)
resp, err := http.Post("http://"+addr+"/v1/fhe/encrypt", "application/json", in)
if err != nil || resp.StatusCode != 200 {
t.Fatalf("encrypt: err=%v status=%v", err, resp)
}
var enc struct {
Ciphertext string `json:"ciphertext"`
NumBits int `json:"numBits"`
}
if err := json.NewDecoder(resp.Body).Decode(&enc); err != nil {
t.Fatalf("decode encrypt: %v", err)
}
resp.Body.Close()
if enc.Ciphertext == "" || enc.NumBits != 1 {
t.Fatalf("unexpected encrypt response: %+v", enc)
}
// Decrypt and confirm bit is true.
body, _ := json.Marshal(map[string]string{"ciphertext": enc.Ciphertext})
resp, err = http.Post("http://"+addr+"/v1/fhe/decrypt", "application/json", bytes.NewReader(body))
if err != nil || resp.StatusCode != 200 {
t.Fatalf("decrypt: err=%v status=%v", err, resp)
}
var dec struct {
Bit *bool `json:"bit"`
}
if err := json.NewDecoder(resp.Body).Decode(&dec); err != nil {
t.Fatalf("decode decrypt: %v", err)
}
resp.Body.Close()
if dec.Bit == nil || *dec.Bit != true {
t.Fatalf("round-trip lost the bit: got %+v want true", dec.Bit)
}
}