fix(transport): propagate peer Ed25519 keys to consensus identity store

Consensus mode learned only its OWN Ed25519 public key: peer keys arrive in
ReadySignal.PublicKey during the transport identity handshake (sendIdentity) but
were discarded — never fed to the ConsensusIdentityStore that VerifyWireMessage
consults. Result: every inbound CGGMP21/FROST/signing wire message from a peer
was dropped ("public key not found for node …") and keygen/signing stalled to a
60s timeout. The HTTP /keygen and /sign endpoints could not work in any real
multi-node ensemble.

Fix (orthogonal, no import cycle): transport.Config.OnPeerIdentity observer is
invoked (notifyPeerIdentity, with self/empty/length guards + defensive copy)
whenever a peer key is learned — from the MsgMPCReady handshake in handleMessage
and handleIncoming. The acceptor now also replies with its own identity so the
key exchange is symmetric on the single tie-broken connection. main.go wires the
observer to ConsensusIdentityStore.AddPeerPublicKey. This is the consensus-mode
analog of the legacy NATS path's peers.json + *_identity.json seeding.

Proof: e2e/live_http_sign_test.go (//go:build integration) builds mpcd from the
repo root, starts a real 3-node 2-of-3 consensus ensemble, and over the live
HTTP API proves: distributed CGGMP21 keygen (real evm address), a real threshold
signature, idempotency cached-match, anti-oracle 409 on key reuse with new
content, 400 on empty key, and no plaintext key in any response or node log.
Unit coverage for the observer guards in pkg/transport/peer_identity_test.go.

Also fixes a pre-existing standalone_test.go bug: it set only ZAPDB_PASSWORD but
the env HSM provider reads MPC_PASSWORD — set both so the node starts.
This commit is contained in:
2026-06-26 21:18:02 -07:00
parent 133131d943
commit 255faab1cd
6 changed files with 681 additions and 5 deletions
+9 -1
View File
@@ -360,7 +360,12 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
// Get ZapDB password via HSM provider (supports AWS KMS, GCP Cloud KMS, Azure Key Vault, env, file)
zapDBPassword := resolveZapDBPassword(ctx, c)
// Create transport factory (uses ZapDB for embedded key-share storage)
// Create transport factory (uses ZapDB for embedded key-share storage).
// OnPeerIdentity feeds peer Ed25519 public keys learned from the transport
// identity handshake into the consensus identity store, so wire-message
// signature verification (keygen/signing rounds) can resolve peer keys.
// This is the consensus-mode analog of the legacy NATS path's peers.json +
// *_identity.json seeding; without it every inbound peer message is dropped.
factoryCfg := transport.FactoryConfig{
NodeID: nodeID,
ListenAddr: listenAddr,
@@ -370,6 +375,9 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
ZapDBPath: filepath.Join(dataDir, "db"),
ZapDBPassword: zapDBPassword,
BackupDir: filepath.Join(dataDir, "backups"),
OnPeerIdentity: func(peerNodeID string, peerPubKey ed25519.PublicKey) {
consensusIdentity.AddPeerPublicKey(peerNodeID, peerPubKey)
},
}
factory, err := transport.NewFactory(factoryCfg)
+6 -1
View File
@@ -77,8 +77,13 @@ func TestStandaloneSelfQuorum(t *testing.T) {
)
cmd.Env = append(os.Environ(),
// Standalone uses env-provider for the ZapDB password to keep
// the test self-contained — no cloud KMS required.
// the test self-contained — no cloud KMS required. The `env`
// provider reads MPC_PASSWORD (its native var); ZAPDB_PASSWORD is
// the viper-config fallback. Set BOTH so the password resolves
// regardless of which path the startup takes (the env provider is
// tried first, so MPC_PASSWORD is what actually unlocks ZapDB).
"MPC_HSM_PROVIDER=env",
"MPC_PASSWORD=smoke-test-password",
"ZAPDB_PASSWORD=smoke-test-password",
// Skip the event_initiator_pubkey requirement: in standalone
// mode there's no external event initiator.
+480
View File
@@ -0,0 +1,480 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build integration
// Package e2e, file live_http_sign_test.go.
//
// LIVE MPC custody proof. A real 3-node mpcd ensemble (consensus mode — the
// PRODUCTION code path: ZAP transport, CGGMP21 keygen, t-of-n threshold
// signing) is started, and we drive it over the REAL internal HTTP API
// (`/keygen`, `/sign`) exactly as the lux/wallet custody adapter does. This is
// not a mock: every signature is a genuine threshold signature produced from
// key shares that never leave each node's encrypted ZapDB. No plaintext private
// key is ever assembled, and the test asserts none appears in any response or
// node log.
//
// What it proves end to end:
// - 3-node 2-of-3 ensemble forms a signing quorum over the ZAP transport.
// - POST /keygen → a real distributed keypair (ecdsa pubkey + evm/btc address).
// - POST /sign → a real threshold signature over a 32-byte payload hash.
// - Idempotency (anti-oracle): same key+content → cached identical signature;
// same key+different content → HTTP 409, no signing; empty key → 400.
// - No plaintext key leaks (response bodies + node logs scanned).
//
// Run:
//
// cd /Users/z/work/lux/mpc && \
// GOEXPERIMENT=runtimesecret,simd go test -tags integration ./e2e/ \
// -run TestLiveHTTPSign -count=1 -timeout 300s -v
//
// mpcd is built from the repo ROOT module (the checked-out source), not from
// the e2e module's pinned luxfi/mpc dependency — so this exercises the local
// tree. See buildMPCD.
package e2e
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/google/uuid"
)
// internalAPIKey is the bearer token every node in the ensemble shares. Set as
// MPC_INTERNAL_API_KEY so the test holds the token (it is a test fixture — fine
// to embed/log). In production this is injected from the KMS-synced Secret.
const internalAPIKey = "live-e2e-internal-api-key"
// zapdbPassword unlocks each node's embedded ZapDB. The `env` HSM provider reads
// MPC_PASSWORD; the consensus startup's viper fallback reads ZAPDB_PASSWORD — we
// set both to the same value so either path resolves it.
const zapdbPassword = "live-e2e-zapdb-password"
// ensemble is a running set of mpcd nodes plus their HTTP API addresses.
type ensemble struct {
t *testing.T
apiAddrs []string // index → "127.0.0.1:<port>"
procs []*exec.Cmd
logs []*syncBuf
}
// syncBuf is a goroutine-safe byte buffer capturing a node's stdout+stderr.
type syncBuf struct {
mu sync.Mutex
buf bytes.Buffer
}
func (s *syncBuf) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.buf.Write(p)
}
func (s *syncBuf) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.buf.String()
}
// freePort returns a currently-free TCP port on loopback.
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
}
// buildMPCD compiles mpcd from the repo ROOT module (../cmd/mpcd) with the
// canonical build flags from the Makefile (GOWORK=off, GOEXPERIMENT). CGO is
// disabled so the binary is self-contained. Returns the binary path.
func buildMPCD(t *testing.T) string {
t.Helper()
bin := filepath.Join(t.TempDir(), "mpcd")
cmd := exec.Command("go", "build", "-o", bin, "./cmd/mpcd")
cmd.Dir = ".." // repo root module (the checked-out feat/mpcd-http-sign tree)
cmd.Env = append(os.Environ(),
"GOWORK=off",
"GOEXPERIMENT=runtimesecret,simd",
"CGO_ENABLED=0",
)
var out bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &out
t.Logf("building mpcd from repo root (../cmd/mpcd)…")
if err := cmd.Run(); err != nil {
t.Fatalf("go build mpcd: %v\n%s", err, out.String())
}
return bin
}
// startEnsemble launches n consensus-mode mpcd nodes with the given threshold,
// fully meshed, each on its own ports + data/keys dirs, and waits until every
// node reports signing_quorum=true (or fails with health + log dumps).
func startEnsemble(t *testing.T, n, threshold int) *ensemble {
t.Helper()
if n < 2 {
t.Fatalf("ensemble needs >= 2 nodes (CGGMP21/FROST cannot keygen with 1), got %d", n)
}
bin := buildMPCD(t)
root := t.TempDir()
nodeIDs := make([]string, n)
listen := make([]int, n)
api := make([]int, n)
for i := 0; i < n; i++ {
nodeIDs[i] = fmt.Sprintf("node%d", i)
listen[i] = freePort(t)
api[i] = freePort(t)
}
e := &ensemble{t: t, apiAddrs: make([]string, n), procs: make([]*exec.Cmd, n), logs: make([]*syncBuf, n)}
for i := 0; i < n; i++ {
// Wire every OTHER node as a --peer.
args := []string{
"start",
"--node-id", nodeIDs[i],
"--listen", fmt.Sprintf("127.0.0.1:%d", listen[i]),
"--api", fmt.Sprintf("127.0.0.1:%d", api[i]),
"--data", filepath.Join(root, nodeIDs[i]),
"--keys", filepath.Join(root, nodeIDs[i], "keys"),
"--threshold", fmt.Sprint(threshold),
"--log-level", "info",
// Disable the optional servers the proof does not need so the node
// is the minimal consensus + internal-HTTP surface.
"--kms-zap-listen", "",
"--threshold-listen", "",
}
for j := 0; j < n; j++ {
if j == i {
continue
}
args = append(args, "--peer", fmt.Sprintf("%[email protected]:%d", nodeIDs[j], listen[j]))
}
cmd := exec.Command(bin, args...)
cmd.Env = append(os.Environ(),
"MPC_HSM_PROVIDER=env",
"MPC_PASSWORD="+zapdbPassword, // hsm/env provider's native var
"ZAPDB_PASSWORD="+zapdbPassword, // viper fallback used by consensus startup
"MPC_INTERNAL_API_KEY="+internalAPIKey,
// Deliberately NO MPC_JWT_SECRET → the Base dashboard does not start.
)
lb := &syncBuf{}
cmd.Stdout = io.MultiWriter(lb, testLogWriter{t, nodeIDs[i]})
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
t.Fatalf("start %s: %v", nodeIDs[i], err)
}
e.procs[i] = cmd
e.logs[i] = lb
e.apiAddrs[i] = fmt.Sprintf("127.0.0.1:%d", api[i])
}
t.Cleanup(e.stop)
e.waitQuorum(90 * time.Second)
return e
}
// testLogWriter forwards node output to t.Log line-prefixed, for live debugging.
type testLogWriter struct {
t *testing.T
id string
}
func (w testLogWriter) Write(p []byte) (int, error) {
for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
if line != "" {
w.t.Logf("[%s] %s", w.id, line)
}
}
return len(p), nil
}
// waitQuorum polls every node's /healthz until all report signing_quorum=true
// AND ready=true (ready = all peers connected, which keygen requires). Fails
// with the last health bodies + node logs if the deadline passes.
func (e *ensemble) waitQuorum(timeout time.Duration) {
e.t.Helper()
deadline := time.Now().Add(timeout)
last := make([]string, len(e.apiAddrs))
for time.Now().Before(deadline) {
allReady := true
for i, addr := range e.apiAddrs {
body, code := e.health(addr)
last[i] = body
if code != http.StatusOK {
allReady = false
continue
}
var h struct {
SigningQuorum bool `json:"signing_quorum"`
Ready bool `json:"ready"`
}
_ = json.Unmarshal([]byte(body), &h)
// keygen needs ALL peers (ready=true); signing needs only quorum.
// Gate on the stricter condition so keygen is reliable.
if !h.SigningQuorum || !h.Ready {
allReady = false
}
}
if allReady {
e.t.Logf("ensemble healthy: all %d nodes report signing_quorum=true, ready=true", len(e.apiAddrs))
return
}
time.Sleep(500 * time.Millisecond)
}
for i := range e.apiAddrs {
e.t.Logf("node%d last health: %s", i, last[i])
e.t.Logf("node%d log tail:\n%s", i, tail(e.logs[i].String(), 40))
}
e.t.Fatalf("ensemble never reached full readiness within %s", timeout)
}
func (e *ensemble) health(addr string) (string, int) {
resp, err := http.Get("http://" + addr + "/healthz")
if err != nil {
return err.Error(), 0
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b), resp.StatusCode
}
// post issues an authenticated POST to a node's internal API and returns the
// status code + decoded body map.
func (e *ensemble) post(addr, path string, body any) (int, map[string]any) {
e.t.Helper()
buf, err := json.Marshal(body)
if err != nil {
e.t.Fatalf("marshal: %v", err)
}
req, err := http.NewRequest(http.MethodPost, "http://"+addr+path, bytes.NewReader(buf))
if err != nil {
e.t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+internalAPIKey)
client := &http.Client{Timeout: 90 * time.Second}
resp, err := client.Do(req)
if err != nil {
e.t.Fatalf("POST %s: %v", path, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var out map[string]any
_ = json.Unmarshal(raw, &out)
return resp.StatusCode, out
}
// stop SIGTERMs every node and waits briefly, killing any straggler. Registered
// as a t.Cleanup so no process or port leaks across tests.
func (e *ensemble) stop() {
for _, cmd := range e.procs {
if cmd == nil || cmd.Process == nil {
continue
}
_ = cmd.Process.Signal(syscall.SIGTERM)
}
for _, cmd := range e.procs {
if cmd == nil || cmd.Process == nil {
continue
}
done := make(chan struct{})
go func(c *exec.Cmd) { _, _ = c.Process.Wait(); close(done) }(cmd)
select {
case <-done:
case <-time.After(10 * time.Second):
_ = cmd.Process.Kill()
<-done
}
}
}
// allLogs concatenates every node's captured output — used for the no-leak scan.
func (e *ensemble) allLogs() string {
var b strings.Builder
for i, lb := range e.logs {
fmt.Fprintf(&b, "=== node%d ===\n%s\n", i, lb.String())
}
return b.String()
}
func tail(s string, lines int) string {
parts := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(parts) <= lines {
return s
}
return strings.Join(parts[len(parts)-lines:], "\n")
}
// TestLiveHTTPSign is the live keygen + threshold-sign + idempotency proof
// against a real 3-node 2-of-3 consensus ensemble over the HTTP endpoints.
func TestLiveHTTPSign(t *testing.T) {
e := startEnsemble(t, 3, 2) // 3 nodes, 2-of-3 — the canonical ensemble.
node0 := e.apiAddrs[0]
// --- 1) LIVE KEYGEN (needs all 3 peers ready; allow a couple retries since
// a fresh ensemble can race the first round). ---
walletID := uuid.NewString()
var keygen map[string]any
var evmAddr, ecdsaPub string
for attempt := 1; attempt <= 3; attempt++ {
code, body := e.post(node0, "/keygen", map[string]string{
"org_id": "test-org",
"wallet_id": walletID,
})
if code == http.StatusOK {
if rt, _ := body["result_type"].(string); rt == "success" {
keygen = body
evmAddr, _ = body["evm_address"].(string)
ecdsaPub, _ = body["ecdsa_pub_key"].(string)
break
}
}
t.Logf("keygen attempt %d not yet successful: code=%d body=%v", attempt, code, body)
// New walletID per attempt so a stale in-flight session can't collide.
walletID = uuid.NewString()
time.Sleep(2 * time.Second)
}
if keygen == nil {
t.Fatalf("LIVE KEYGEN failed after retries; node logs:\n%s", e.allLogs())
}
if ecdsaPub == "" {
t.Fatalf("keygen returned empty ecdsa_pub_key: %v", keygen)
}
if evmAddr == "" || !strings.HasPrefix(evmAddr, "0x") || len(evmAddr) != 42 {
t.Fatalf("keygen returned bad evm_address %q: %v", evmAddr, keygen)
}
t.Logf("LIVE KEYGEN OK: wallet=%s evm_address=%s ecdsa_pub_key=%s…",
walletID, evmAddr, truncate(ecdsaPub, 16))
// --- 2) LIVE THRESHOLD SIGN over a real 32-byte payload hash. ---
digest := sha256.Sum256([]byte("live-e2e fake tx for " + walletID))
payloadHash := hex.EncodeToString(digest[:])
idemKey := uuid.NewString()
signBody := map[string]any{
"org_id": "test-org",
"wallet_id": walletID,
"key_type": "secp256k1",
"chain_id": 96369,
"payload_hash": payloadHash,
"idempotency_key": idemKey,
}
code, sign1 := e.post(node0, "/sign", signBody)
if code != http.StatusOK {
t.Fatalf("LIVE SIGN failed: code=%d body=%v; logs:\n%s", code, sign1, e.allLogs())
}
if status, _ := sign1["status"].(string); status != "signed" {
t.Fatalf("sign status = %v, want signed: %v", sign1["status"], sign1)
}
if errStr, _ := sign1["error"].(string); errStr != "" {
t.Fatalf("sign returned error: %q", errStr)
}
sig1, _ := sign1["signature"].(string)
if sig1 == "" {
t.Fatalf("sign returned empty signature: %v", sign1)
}
if _, err := hex.DecodeString(sig1); err != nil {
t.Fatalf("signature is not valid hex: %q (%v)", sig1, err)
}
t.Logf("LIVE THRESHOLD SIGN OK: signature=%s… session=%v", truncate(sig1, 32), sign1["session_id"])
// --- 3) IDEMPOTENCY: same key + same content → cached identical signature
// (signer NOT re-run). ---
code, sign2 := e.post(node0, "/sign", signBody)
if code != http.StatusOK {
t.Fatalf("idempotent re-sign failed: code=%d body=%v", code, sign2)
}
sig2, _ := sign2["signature"].(string)
if sig2 != sig1 {
t.Fatalf("idempotency BROKEN: re-sign with same key returned different signature\n got=%s\nwant=%s", sig2, sig1)
}
t.Logf("IDEMPOTENCY cached-match OK: identical signature returned for repeated (key,content)")
// --- 4) ANTI-ORACLE: same idempotency key, DIFFERENT payload → HTTP 409,
// no signing. ---
conflictBody := map[string]any{}
for k, v := range signBody {
conflictBody[k] = v
}
other := sha256.Sum256([]byte("a DIFFERENT tx under the same idempotency key"))
conflictBody["payload_hash"] = hex.EncodeToString(other[:])
code, conflict := e.post(node0, "/sign", conflictBody)
if code != http.StatusConflict {
t.Fatalf("anti-oracle BROKEN: reused idempotency key with new content → code=%d (want 409); body=%v", code, conflict)
}
t.Logf("ANTI-ORACLE 409 OK: reused key + different content refused without signing: %v", conflict["error"])
// --- 5) Boundary: empty idempotency key → 400. ---
emptyKeyBody := map[string]any{}
for k, v := range signBody {
emptyKeyBody[k] = v
}
emptyKeyBody["idempotency_key"] = ""
code, badReq := e.post(node0, "/sign", emptyKeyBody)
if code != http.StatusBadRequest {
t.Fatalf("empty idempotency key → code=%d (want 400); body=%v", code, badReq)
}
t.Logf("BOUNDARY 400 OK: empty idempotency_key rejected: %v", badReq["error"])
// --- 6) NO PLAINTEXT KEY ANYWHERE. The design never assembles a full
// private key; assert no response or node log emits one. We scan for the
// obvious secret markers AND for any 64-hex token that is NOT one of the
// known PUBLIC values (pubkey/signature/payload). ---
assertNoKeyLeak(t, e, ecdsaPub, sig1, payloadHash, fmt.Sprint(sign1), fmt.Sprint(keygen))
t.Logf("NO-LEAK OK: no plaintext private key in any response or node log")
}
// truncate returns up to n leading characters of s — for log readability.
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
// assertNoKeyLeak fails if any captured response or node log contains a marker
// of private key material. The MPC design never assembles a full key, so the
// only key-shaped material that should ever appear is PUBLIC (pubkeys,
// addresses, signatures, protocol ssids/hashes).
func assertNoKeyLeak(t *testing.T, e *ensemble, public ...string) {
t.Helper()
haystack := strings.ToLower(e.allLogs() + " " + strings.Join(public, " "))
for _, marker := range []string{
"private_key", "privatekey", "priv_key", "privkey",
"secret_key", "secretkey", "secret_share", "private share",
"mnemonic", "seed phrase", "plaintext key",
} {
// The node DOES legitimately log the string "TLS identity keys" and
// "Ed25519 keys" — those are about transport key PRESENCE, not values,
// and don't match the markers above. So any hit here is a real leak.
if strings.Contains(haystack, marker) {
// Show the offending lines for diagnosis.
for _, line := range strings.Split(e.allLogs(), "\n") {
if strings.Contains(strings.ToLower(line), marker) {
t.Errorf("possible key leak (marker %q): %s", marker, line)
}
}
}
}
}
+10 -3
View File
@@ -94,6 +94,12 @@ type FactoryConfig struct {
// BackupDir for ZapDB incremental S3 backups
BackupDir string
// OnPeerIdentity, if set, is invoked when this node learns a peer's
// Ed25519 public key from the transport identity exchange. Wire it to the
// consensus identity store's AddPeerPublicKey so wire-message signature
// verification can resolve peer keys. See transport.Config.OnPeerIdentity.
OnPeerIdentity func(nodeID string, pubKey ed25519.PublicKey)
}
// Factory creates and manages the consensus-embedded transport stack
@@ -135,9 +141,10 @@ func NewFactory(config FactoryConfig) (*Factory, error) {
Peers: config.Peers,
PrivateKey: config.PrivateKey,
PublicKey: config.PublicKey,
ReadTimeout: DefaultConfig().ReadTimeout,
WriteTimeout: DefaultConfig().WriteTimeout,
BufferSize: DefaultConfig().BufferSize,
ReadTimeout: DefaultConfig().ReadTimeout,
WriteTimeout: DefaultConfig().WriteTimeout,
BufferSize: DefaultConfig().BufferSize,
OnPeerIdentity: config.OnPeerIdentity,
}
// Create transport
+128
View File
@@ -0,0 +1,128 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Unit tests for the consensus peer-identity propagation fix.
//
// Bug (pre-fix): in consensus mode each node's identity store only knew its
// OWN Ed25519 public key. Peer keys arrive in ReadySignal.PublicKey during the
// transport identity handshake (sendIdentity) but were discarded — never fed to
// the identity store. Result: VerifyWireMessage could not resolve a peer's key,
// so every inbound CGGMP21/FROST/signing wire message was dropped
// ("public key not found for node: …") and keygen/signing stalled until timeout.
//
// Fix: transport.Config.OnPeerIdentity is invoked (via notifyPeerIdentity)
// whenever a peer's key is learned from the handshake, with boundary guards.
// main.go wires it to ConsensusIdentityStore.AddPeerPublicKey — the
// consensus-mode analog of the legacy NATS path's peers.json + *_identity.json
// seeding (pkg/identity.fileStore).
//
// These tests pin the guard logic of notifyPeerIdentity without standing up a
// real TLS mesh (that path is exercised end-to-end by ../../e2e
// live_http_sign_test.go).
package transport
import (
"crypto/ed25519"
"testing"
)
// newTestTransport builds a Transport with just enough config to exercise
// notifyPeerIdentity. No listener, no peers — we only call the helper directly.
func newTestTransport(t *testing.T, nodeID string, onPeer func(string, ed25519.PublicKey)) *Transport {
t.Helper()
return &Transport{
config: &Config{
NodeID: nodeID,
OnPeerIdentity: onPeer,
},
}
}
func freshPubKey(t *testing.T) ed25519.PublicKey {
t.Helper()
pub, _, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
return pub
}
// Test_NotifyPeerIdentity_ForwardsValidPeerKey is the core of the fix: a real
// peer key for a different node reaches the observer, and the observer receives
// a COPY (mutating the source afterwards must not change what was delivered).
func Test_NotifyPeerIdentity_ForwardsValidPeerKey(t *testing.T) {
var gotID string
var gotKey ed25519.PublicKey
calls := 0
tr := newTestTransport(t, "node0", func(id string, key ed25519.PublicKey) {
calls++
gotID = id
gotKey = key
})
peerKey := freshPubKey(t)
tr.notifyPeerIdentity("node1", peerKey)
if calls != 1 {
t.Fatalf("expected observer called once, got %d", calls)
}
if gotID != "node1" {
t.Fatalf("expected nodeID=node1, got %q", gotID)
}
if len(gotKey) != ed25519.PublicKeySize {
t.Fatalf("expected %d-byte key, got %d", ed25519.PublicKeySize, len(gotKey))
}
if !gotKey.Equal(peerKey) {
t.Fatal("delivered key does not match source key")
}
// Defensive-copy proof: mutating the source slice must not affect the
// already-delivered key (the wire buffer can be reused by the caller).
peerKey[0] ^= 0xFF
if gotKey.Equal(peerKey) {
t.Fatal("observer received a reference to the caller's slice, not a copy")
}
}
// Test_NotifyPeerIdentity_Guards covers every short-circuit: self, empty
// nodeID, and a wrong-length key must NOT reach the observer. This is what
// keeps the identity store from ever being polluted with a non-peer or a
// malformed key.
func Test_NotifyPeerIdentity_Guards(t *testing.T) {
good := freshPubKey(t)
short := make(ed25519.PublicKey, 16) // too short to be a real Ed25519 key
cases := []struct {
name string
nodeID string
key ed25519.PublicKey
wantCalls int
}{
{"valid peer", "node1", good, 1},
{"self is skipped", "node0", good, 0},
{"empty nodeID skipped", "", good, 0},
{"wrong-length key skipped", "node1", short, 0},
{"nil key skipped", "node1", nil, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
calls := 0
tr := newTestTransport(t, "node0", func(string, ed25519.PublicKey) { calls++ })
tr.notifyPeerIdentity(tc.nodeID, tc.key)
if calls != tc.wantCalls {
t.Fatalf("nodeID=%q keyLen=%d → observer calls=%d, want %d",
tc.nodeID, len(tc.key), calls, tc.wantCalls)
}
})
}
}
// Test_NotifyPeerIdentity_NilObserver proves the helper is a safe no-op when no
// observer is configured (e.g. a standalone node, or a caller that does not
// need dynamic peer-key learning). Must not panic.
func Test_NotifyPeerIdentity_NilObserver(t *testing.T) {
tr := newTestTransport(t, "node0", nil)
// Should not panic and should do nothing observable.
tr.notifyPeerIdentity("node1", freshPubKey(t))
}
+48
View File
@@ -51,6 +51,18 @@ type Config struct {
// BufferSize for read/write buffers
BufferSize int
// OnPeerIdentity, if set, is invoked whenever this node learns a peer's
// Ed25519 public key from the identity exchange (the MsgMPCReady handshake
// carried in ReadySignal.PublicKey). It lets the consensus identity store
// learn peer keys dynamically — the consensus-mode analog of the legacy
// NATS path's peers.json + *_identity.json seeding (pkg/identity.fileStore).
// Without it, wire-message signature verification cannot find peer keys and
// every inbound keygen/signing message is dropped. The transport stays
// ignorant of the identity store's concrete type (no import cycle): it just
// reports (nodeID, pubKey). Idempotent — may be called more than once per
// peer across reconnects.
OnPeerIdentity func(nodeID string, pubKey ed25519.PublicKey)
}
// DefaultConfig returns sensible defaults
@@ -302,6 +314,18 @@ func (t *Transport) handleIncoming(ctx context.Context, conn net.Conn) {
}
logger.Info("Incoming connection from peer", "nodeID", ready.NodeID)
// Learn the peer's Ed25519 public key from its identity message. The
// dialer sends its identity right after connecting (sendIdentity); the
// acceptor reads it here. Propagate it so wire-message verification can
// find this peer's key.
t.notifyPeerIdentity(ready.NodeID, ready.PublicKey)
// Send our identity back over the surviving connection so the dialer
// learns OUR key too. Tie-break keeps a single connection per pair, so the
// acceptor must reply or the dialer would never receive the acceptor's
// key and one direction of verification would fail.
t.sendIdentity(peer)
// Handle messages from this peer
t.handlePeer(ctx, peer)
@@ -407,6 +431,10 @@ func (t *Transport) handleMessage(from string, msgType uint8, payload []byte) {
return
}
logger.Debug("Received ready signal", "from", ready.NodeID, "ready", ready.Ready)
// An identity message carries the sender's Ed25519 public key. The
// dialer learns the acceptor's key here (the acceptor replies via
// sendIdentity in handleIncoming); re-announcements refresh it.
t.notifyPeerIdentity(ready.NodeID, ready.PublicKey)
case MsgMPCPing:
// Send pong back
@@ -578,6 +606,26 @@ func (t *Transport) broadcast(msgType uint8, payload []byte) error {
return nil
}
// notifyPeerIdentity reports a learned peer Ed25519 public key to the
// OnPeerIdentity observer (if configured). It skips empty keys, our own
// nodeID, and wrong-length keys so the observer only ever receives a real
// peer's valid Ed25519 public key. Idempotent at the observer.
func (t *Transport) notifyPeerIdentity(nodeID string, pubKey ed25519.PublicKey) {
if t.config.OnPeerIdentity == nil {
return
}
if nodeID == "" || nodeID == t.config.NodeID {
return
}
if len(pubKey) != ed25519.PublicKeySize {
return
}
// Copy: the unmarshalled slice may be reused by the caller's buffer.
keyCopy := make(ed25519.PublicKey, ed25519.PublicKeySize)
copy(keyCopy, pubKey)
t.config.OnPeerIdentity(nodeID, keyCopy)
}
// sendIdentity sends our identity to a peer
func (t *Transport) sendIdentity(peer *peerConn) {
ready := ReadySignal{