Files
zeekayandHanzo Dev a20ace0dd0 a request names the channel it is for, and is honoured only there
Key agreement gives two endpoints a private channel. It does not tell
either of them who is on the far end, so a signature on a request proves
who wrote it and not who they wrote it to. Anyone able to agree keys with
both sides can therefore carry a signed request from one channel to
another and be served on it.

The handshake now derives a second value alongside the session key, under
its own label: a binding that names the channel. Only the two endpoints
that ran that handshake can compute it. The caller signs it into every
envelope; the peer checks it against its own side of the channel the
envelope arrived on. A request written for one channel is inert on any
other — it cannot be re-signed without the caller's key, and a channel
cannot be made to produce a name it did not derive.

It is not optional anywhere it could be skipped:

  - NewSession takes the handshake result, so a session always carries
    the binding derived with its key.
  - envelope.Build requires one, so an envelope addressed to nobody
    cannot be signed; Parse requires one, so it cannot be read.
  - envelope.Verify takes the arriving channel as a parameter, so there
    is no way to verify without saying where the request came from.
  - Both transports supply their own: the ZAP wire its session's, the
    HTTP surface the TLS exporter. A connection with no keys of its own
    has no binding and can carry no envelope.

zapclient loses SkipHandshake and RequireSession. With the binding
required, a client without a session has nothing to sign and cannot
speak, so the dial either agrees keys or fails, and a peer that answers
classical-only when ML-KEM-768 was offered is refused outright rather
than by configuration. VerifyEnvelope goes with them: a verify entry
that checked neither the ledger nor the channel had no callers and no
business existing.

Tests: an attacker that terminates and re-originates both halves of the
handshake, built from the shipping client and server, is refused — both
when it forwards the envelope verbatim and when it rewrites the name to
its own. A caller reaching the KMS directly still gets its secret.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-15 04:49:47 -07:00

181 lines
6.8 KiB
Go

// 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) {
// The deploy mnemonic is the ROOT secret from which every derived
// validator/service key descends. The dial establishes a hybrid
// session or fails, and the request names that session, so the
// mnemonic is answered to this caller over this channel or not at
// all.
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:]
}