keys: rip *ServiceIdentity from LoadMnemonic API

Fetching a secret should not know about who is asking — that's a
transport-layer concern. Trust at the network boundary (NetworkPolicy
+ ZAP wire), not in the application envelope.

API change (breaking):
  LoadMnemonic(ctx, addr, env, path) string, error
  LoadMnemonicFromKMS(ctx, addr, env, path) string, error
  dialKMS(ctx, addr) (MnemonicReader, error)

ServiceIdentity type is retained for callers that need signing
elsewhere; it's just no longer threaded through the mnemonic-fetch
operation.

Companion rip: KMS_ZAP_AUTH_ENABLED, ZAP_AUTH_REQUIRED, KMS_IAM_URL,
KMS_IAM_CLIENT_ID, KMS_IAM_CLIENT_SECRET, KMS_ZAP_AUDIENCE — see
luxfi/kms + luxfi/mpc follow-ups.
This commit is contained in:
Hanzo AI
2026-06-06 20:54:42 -07:00
parent d198d9120b
commit 3875616611
2 changed files with 27 additions and 77 deletions
+13 -35
View File
@@ -33,7 +33,6 @@ import (
"strings"
bip39 "github.com/luxfi/go-bip39"
"github.com/luxfi/kms/pkg/envelope"
"github.com/luxfi/kms/pkg/zapclient"
)
@@ -48,22 +47,11 @@ type MnemonicReader interface {
// dialKMS is the production seam. Tests override to inject a fake
// without touching the network.
//
// identity is REQUIRED for the production path (the KMS server's
// consensus-auth gate rejects envelopes without a signed identity).
// Passing nil dials anonymously — accepted only by legacy KMS peers
// that have explicitly disabled the auth gate (dev / loopback only).
var dialKMS = func(ctx context.Context, addr string, identity *ServiceIdentity) (MnemonicReader, error) {
cfg := zapclient.Config{PeerAddr: addr}
if identity != nil {
cfg.IdentityHeader = envelope.IdentityHeader{
NodeID: identity.NodeID,
FullDigest: identity.FullDigest,
ServicePath: identity.ServicePath,
PublicKey: identity.PublicKey,
}
cfg.Signer = identity
}
c, err := zapclient.DialWithConfig(ctx, cfg)
// Trust is enforced at the network boundary (NetworkPolicy + ZAP
// wire), not in the application-layer envelope. The mnemonic-fetch
// operation does not know about who is asking; it just asks.
var dialKMS = func(ctx context.Context, addr string) (MnemonicReader, error) {
c, err := zapclient.DialWithConfig(ctx, zapclient.Config{PeerAddr: addr})
if err != nil {
return nil, err
}
@@ -74,38 +62,28 @@ var dialKMS = func(ctx context.Context, addr string, identity *ServiceIdentity)
// MNEMONIC env wins when set; otherwise dials KMS over native ZAP at
// `addr` and reads the secret at `path` under `env`.
//
// ctx cancellable context
// addr KMS host:port (e.g. "kms.internal.svc:9999")
// env KMS env scope ("mainnet" | "testnet" | "devnet")
// path KMS secret path (e.g. "/mnemonic" or "/foo/master")
// identity ServiceIdentity to sign the secret-opcode envelope. May
// be nil for legacy peers and the env-win short-circuit
// (the KMS dial is never reached); required for production
// peers whose consensus-auth gate is engaged.
// ctx cancellable context
// addr KMS host:port (e.g. "kms.internal.svc:9999")
// env KMS env scope ("mainnet" | "testnet" | "devnet")
// path KMS secret path (e.g. "/mnemonic" or "/foo/master")
//
// Returns the validated BIP-39 phrase. Caller is responsible for
// keeping it on the goroutine stack and not logging / persisting it.
func LoadMnemonic(ctx context.Context, addr, env, path string, identity *ServiceIdentity) (string, error) {
func LoadMnemonic(ctx context.Context, addr, env, path string) (string, error) {
if e := strings.TrimSpace(os.Getenv("MNEMONIC")); e != "" {
if !bip39.IsMnemonicValid(e) {
return "", errors.New("MNEMONIC env is not a valid BIP-39 phrase")
}
return e, nil
}
return LoadMnemonicFromKMS(ctx, addr, env, path, identity)
return LoadMnemonicFromKMS(ctx, addr, env, path)
}
// LoadMnemonicFromKMS is the production-only path (no MNEMONIC env
// short-circuit). Useful when a caller wants to force the KMS read
// regardless of ambient env — e.g., a regression test pinning the
// production code path.
//
// identity is the *ServiceIdentity threaded into the ZAP dial: its
// IdentityHeader becomes envelope.Identity and the same identity
// signs every secret-opcode envelope. Pass nil only when dialling a
// legacy KMS peer that has not enabled the consensus-auth gate
// (dev / loopback fakes — see zap_test.go's withDial seam).
func LoadMnemonicFromKMS(ctx context.Context, addr, env, path string, identity *ServiceIdentity) (string, error) {
func LoadMnemonicFromKMS(ctx context.Context, addr, env, path string) (string, error) {
if addr == "" {
return "", errors.New("keys.LoadMnemonicFromKMS: KMS addr is required")
}
@@ -116,7 +94,7 @@ func LoadMnemonicFromKMS(ctx context.Context, addr, env, path string, identity *
return "", errors.New("keys.LoadMnemonicFromKMS: KMS path is required")
}
c, err := dialKMS(ctx, addr, identity)
c, err := dialKMS(ctx, addr)
if err != nil {
return "", fmt.Errorf("dial KMS %s: %w", addr, err)
}
+14 -42
View File
@@ -31,20 +31,16 @@ func (f *fakeKMS) GetAt(_ context.Context, path, name, env string) (string, erro
}
func (f *fakeKMS) Close() { f.closed = true }
// gotIdent captures the identity threaded into the dial seam so the
// per-call-site identity-wiring test can assert it reached zapclient.
type gotIdent struct {
called bool
identity *ServiceIdentity
}
// dialState captures dial-seam invocations so tests can assert the
// dial path was (or wasn't) reached.
type dialState struct{ called bool }
func withDial(t *testing.T, f *fakeKMS, err error) *gotIdent {
func withDial(t *testing.T, f *fakeKMS, err error) *dialState {
t.Helper()
prev := dialKMS
g := &gotIdent{}
dialKMS = func(_ context.Context, _ string, id *ServiceIdentity) (MnemonicReader, error) {
g := &dialState{}
dialKMS = func(_ context.Context, _ string) (MnemonicReader, error) {
g.called = true
g.identity = id
if err != nil {
return nil, err
}
@@ -59,7 +55,7 @@ func TestLoadMnemonic_EnvWins(t *testing.T) {
t.Setenv("MNEMONIC", validBIP39+"\n")
f := &fakeKMS{value: "ignored"}
g := withDial(t, f, nil)
got, err := LoadMnemonic(context.Background(), "addr", "main", "/mnemonic", nil)
got, err := LoadMnemonic(context.Background(), "addr", "main", "/mnemonic")
if err != nil {
t.Fatalf("LoadMnemonic: %v", err)
}
@@ -74,7 +70,7 @@ func TestLoadMnemonic_EnvWins(t *testing.T) {
// Invalid MNEMONIC env fails fast — does NOT silently fall through to KMS.
func TestLoadMnemonic_EnvInvalid(t *testing.T) {
t.Setenv("MNEMONIC", "not a bip39 phrase")
_, err := LoadMnemonic(context.Background(), "addr", "main", "/mnemonic", nil)
_, err := LoadMnemonic(context.Background(), "addr", "main", "/mnemonic")
if err == nil || !strings.Contains(err.Error(), "MNEMONIC env") {
t.Fatalf("expected MNEMONIC-env error, got %v", err)
}
@@ -85,7 +81,7 @@ func TestLoadMnemonic_FallsThroughToKMS(t *testing.T) {
t.Setenv("MNEMONIC", "")
f := &fakeKMS{value: validBIP39}
withDial(t, f, nil)
got, err := LoadMnemonic(context.Background(), "addr", "main", "/mnemonic", nil)
got, err := LoadMnemonic(context.Background(), "addr", "main", "/mnemonic")
if err != nil {
t.Fatalf("LoadMnemonic: %v", err)
}
@@ -110,7 +106,7 @@ func TestLoadMnemonicFromKMS_RequiredArgs(t *testing.T) {
{"a", "main", "", "path is required"},
}
for _, c := range cases {
_, err := LoadMnemonicFromKMS(context.Background(), c.addr, c.env, c.path, nil)
_, err := LoadMnemonicFromKMS(context.Background(), c.addr, c.env, c.path)
if err == nil || !strings.Contains(err.Error(), c.wantMsg) {
t.Errorf("addr=%q env=%q path=%q → %v, want %q",
c.addr, c.env, c.path, err, c.wantMsg)
@@ -121,7 +117,7 @@ func TestLoadMnemonicFromKMS_RequiredArgs(t *testing.T) {
func TestLoadMnemonicFromKMS_InvalidValue(t *testing.T) {
t.Setenv("MNEMONIC", "")
withDial(t, &fakeKMS{value: "not a bip39"}, nil)
_, err := LoadMnemonicFromKMS(context.Background(), "a", "main", "/m", nil)
_, err := LoadMnemonicFromKMS(context.Background(), "a", "main", "/m")
if err == nil || !strings.Contains(err.Error(), "not a valid BIP-39") {
t.Fatalf("got %v", err)
}
@@ -130,7 +126,7 @@ func TestLoadMnemonicFromKMS_InvalidValue(t *testing.T) {
func TestLoadMnemonicFromKMS_EmptyValue(t *testing.T) {
t.Setenv("MNEMONIC", "")
withDial(t, &fakeKMS{value: ""}, nil)
_, err := LoadMnemonicFromKMS(context.Background(), "a", "main", "/m", nil)
_, err := LoadMnemonicFromKMS(context.Background(), "a", "main", "/m")
if err == nil || !strings.Contains(err.Error(), "is empty") {
t.Fatalf("got %v", err)
}
@@ -139,7 +135,7 @@ func TestLoadMnemonicFromKMS_EmptyValue(t *testing.T) {
func TestLoadMnemonicFromKMS_DialError(t *testing.T) {
t.Setenv("MNEMONIC", "")
withDial(t, nil, errors.New("dial timeout"))
_, err := LoadMnemonicFromKMS(context.Background(), "a", "main", "/m", nil)
_, err := LoadMnemonicFromKMS(context.Background(), "a", "main", "/m")
if err == nil || !strings.Contains(err.Error(), "dial timeout") {
t.Fatalf("got %v", err)
}
@@ -149,7 +145,7 @@ func TestLoadMnemonicFromKMS_GetError(t *testing.T) {
t.Setenv("MNEMONIC", "")
f := &fakeKMS{getErr: errors.New("kms 403")}
withDial(t, f, nil)
_, err := LoadMnemonicFromKMS(context.Background(), "a", "main", "/m", nil)
_, err := LoadMnemonicFromKMS(context.Background(), "a", "main", "/m")
if err == nil || !strings.Contains(err.Error(), "kms 403") {
t.Fatalf("got %v", err)
}
@@ -158,30 +154,6 @@ func TestLoadMnemonicFromKMS_GetError(t *testing.T) {
}
}
// TestLoadMnemonicFromKMS_ThreadsIdentity asserts the *ServiceIdentity
// argument reaches the dial seam unchanged. Without this guarantee
// the production dial would silently drop identity and the KMS
// server's consensus-auth gate would refuse the envelope at runtime.
func TestLoadMnemonicFromKMS_ThreadsIdentity(t *testing.T) {
t.Setenv("MNEMONIC", "")
id, err := NewServiceIdentity(validBIP39, "luxd/staking-bootstrap")
if err != nil {
t.Fatalf("NewServiceIdentity: %v", err)
}
defer id.Wipe()
f := &fakeKMS{value: validBIP39}
g := withDial(t, f, nil)
if _, err := LoadMnemonicFromKMS(context.Background(), "a", "main", "/m", id); err != nil {
t.Fatalf("LoadMnemonicFromKMS: %v", err)
}
if !g.called {
t.Fatal("dial seam not called")
}
if g.identity != id {
t.Fatalf("identity not threaded: got %p want %p", g.identity, id)
}
}
func TestSplitSecretPath(t *testing.T) {
cases := []struct {
in, dir, name string