feat(mnemonic): add pkg/mnemonic.Load — cycle-free KMS bootstrap mnemonic loader

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-06-30 15:56:29 -07:00
co-authored by Hanzo Dev
parent 888d5f5a78
commit 95cdad9ee5
3 changed files with 187 additions and 1 deletions
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Lux KMS</title></head>
<body>
<!-- Placeholder served by the go:embed all:web fallback when no UI build is
present. The KMS API (/v1/*, /healthz) is unaffected. Replace by building
the real frontend into this directory. -->
<h1>Lux KMS</h1>
<p>API is running. No UI build present.</p>
</body>
</html>
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.26.4
require (
github.com/cloudflare/circl v1.6.3
github.com/go-jose/go-jose/v4 v4.1.4
github.com/luxfi/go-bip39 v1.1.2
github.com/luxfi/ids v1.2.15
github.com/luxfi/keys v1.1.0
github.com/luxfi/log v1.4.1
@@ -55,7 +56,6 @@ require (
github.com/luxfi/formatting v1.0.1 // indirect
github.com/luxfi/geth v1.16.98 // indirect
github.com/luxfi/go-bip32 v1.0.2 // indirect
github.com/luxfi/go-bip39 v1.1.2 // indirect
github.com/luxfi/math v1.4.1 // indirect
github.com/luxfi/math/big v0.1.0 // indirect
github.com/luxfi/mdns v0.1.1 // indirect
+175
View File
@@ -0,0 +1,175 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package mnemonic loads a BIP-39 bootstrap mnemonic from KMS over
// native ZAP. It is the one canonical path every Lux-derived service
// uses to resolve the bootstrap mnemonic: luxd, netrunner, lux/cli,
// and any descending L1's bootstrap. No file mount, no projected
// volume, no per-consumer copy of the env-vs-KMS precedence chain.
//
// Why this lives in luxfi/kms and not luxfi/keys:
//
// The mnemonic loader composes luxfi/keys (BIP-39 semantics +
// ServiceIdentity) with the KMS wire transport (envelope + zapclient).
// Putting it in luxfi/keys would force keys to import kms — but kms
// already imports keys (zapserver authenticates ServiceIdentity
// envelopes), so that edge would close an import cycle. Hosting the
// loader in kms keeps the dependency acyclic:
//
// kms/pkg/mnemonic → luxfi/keys (ServiceIdentity, BIP-39)
// kms/pkg/mnemonic → kms/pkg/envelope (IdentityHeader, Signer)
// kms/pkg/mnemonic → kms/pkg/zapclient (DialWithConfig, GetAt)
// luxfi/keys ⇏ kms (never — no back edge)
//
// Separation of concerns:
//
// - zap (the protocol) → github.com/luxfi/zap
// - kms/pkg/zapclient → opaque GetAt over native ZAP
// - kms/pkg/envelope → signed-envelope identity surface
// - luxfi/keys.ServiceIdentity → mnemonic-derived signer (ML-DSA-65)
// - this package → BIP-39 validation + the env-vs-KMS
// precedence chain
//
// Precedence (env wins; KMS is the production fallback):
//
// 1. MNEMONIC env var local dev + CI test seam
// 2. KMS at (addr, env, path) native ZAP from KMS
package mnemonic
import (
"context"
"errors"
"fmt"
"os"
"strings"
bip39 "github.com/luxfi/go-bip39"
"github.com/luxfi/keys"
"github.com/luxfi/kms/pkg/envelope"
"github.com/luxfi/kms/pkg/zapclient"
)
// Reader is the minimum surface LoadFromKMS needs from a KMS client.
// The real *zapclient.Client satisfies it; tests inject fakes via the
// dialKMS seam below.
type Reader interface {
GetAt(ctx context.Context, path, name, env string) (string, error)
Close()
}
// dialKMS is the production seam. Tests override it 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),
// and by the bootstrap loader where no identity exists yet (the
// mnemonic being loaded is itself the root every later identity
// derives from).
var dialKMS = func(ctx context.Context, addr string, identity *keys.ServiceIdentity) (Reader, 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,
}
// *keys.ServiceIdentity satisfies envelope.Signer (Sign binds
// the FullDigest into the prehash).
cfg.Signer = identity
}
c, err := zapclient.DialWithConfig(ctx, cfg)
if err != nil {
return nil, err
}
return c, nil
}
// Load returns the BIP-39 mnemonic for the calling service. 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.default.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 the env-win short-circuit (the KMS dial is
// never reached), for legacy peers, and for the bootstrap
// loader; required for production peers whose consensus-auth
// gate is engaged.
//
// Returns the validated BIP-39 phrase. The caller is responsible for
// keeping it on the goroutine stack and not logging / persisting it.
func Load(ctx context.Context, addr, env, path string, identity *keys.ServiceIdentity) (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 LoadFromKMS(ctx, addr, env, path, identity)
}
// LoadFromKMS 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 *keys.ServiceIdentity threaded into the ZAP dial:
// its public block becomes the envelope IdentityHeader and the same
// identity signs every secret-opcode envelope. Pass nil only when
// dialling a legacy KMS peer with the consensus-auth gate disabled, or
// from the bootstrap loader (dev / loopback fakes — see the dialKMS
// seam).
func LoadFromKMS(ctx context.Context, addr, env, path string, identity *keys.ServiceIdentity) (string, error) {
if addr == "" {
return "", errors.New("mnemonic: KMS addr is required")
}
if env == "" {
return "", errors.New("mnemonic: KMS env is required")
}
if path == "" {
return "", errors.New("mnemonic: KMS path is required")
}
c, err := dialKMS(ctx, addr, identity)
if err != nil {
return "", fmt.Errorf("dial KMS %s: %w", addr, err)
}
defer c.Close()
dir, name := SplitSecretPath(path)
v, err := c.GetAt(ctx, dir, name, env)
if err != nil {
return "", fmt.Errorf("read mnemonic (path=%q name=%q env=%q): %w",
dir, name, env, err)
}
m := strings.TrimSpace(v)
if m == "" {
return "", fmt.Errorf("mnemonic at %q is empty", path)
}
if !bip39.IsMnemonicValid(m) {
return "", errors.New("mnemonic from KMS is not a valid BIP-39 phrase")
}
return m, nil
}
// SplitSecretPath turns "/foo/bar/baz" into ("/foo/bar/", "baz"). When
// there is no '/' or only a leading one, the directory is "" and the
// whole remainder is the name (e.g., "/mnemonic" → ("", "mnemonic")).
//
// Exported so every consumer addresses secrets with the same
// convention — one and only one addressing scheme across mnemonic,
// staking keys, gas-payer keys, etc.
func SplitSecretPath(full string) (dir, name string) {
full = strings.TrimSpace(full)
full = strings.TrimPrefix(full, "/")
i := strings.LastIndex(full, "/")
if i < 0 {
return "", full
}
return "/" + full[:i+1], full[i+1:]
}