Files
kms/consensus.go
Hanzo AI ca66adc00a purge: rip remaining Infisical residue — docs, env examples, repo boilerplate
Following the earlier AdminShell+CollectionCRUD rewrite (PR #14) which
already deleted backend/, backend-go/, cloudformation/, helm-charts/,
nginx/, sink/, wasm/, upgrade-impact/, e2e/, migration/, package.json
and switched the frontend to a 184K Hanzo-first build, complete the
cleanup by deleting the remaining Infisical-era residue:

  - docs/                                   (1.0G Infisical mintlify docs)
  - .env.example, .env.dev.example,
    .env.migration.example, .env.test.example
                                            (legacy Postgres/Redis envs;
                                             no Go code references them)
  - .eslintignore, .husky/                  (TS toolchain leftovers)
  - CODE_OF_CONDUCT.md, CONTRIBUTING.md,
    SECURITY.md                             (Infisical repo boilerplate)

Working tree drops from ~1.0G to <600K of content (1.5G total is .git/
which `git gc --aggressive --prune=now` shrinks locally; the remote
keeps its history shape — anyone pulling gets the lean checkout).

Build + test verification:
  go build ./...                            (4 cmd binaries: green)
  go test ./... -count=1 -short             (all packages: ok)

Kept: cmd/, pkg/, sdk/, frontend/ (AdminShell), schema/, examples/,
root Go (audit.go, auth.go, consensus.go, embed.go, jwks.go, mount.go,
versioning.go + tests), Dockerfile, Dockerfile.kms-fetch, Makefile,
LLM.md, CLAUDE.md, DEPRECATED.md, SOVEREIGN-KMS-ARCHITECTURE.md,
TFHE-KMS-ARCHITECTURE.md, LICENSE, README.md, VERSION, .github/
(8 Hanzo workflows: build, build-kms-fetch, check-fe-ts-and-lint,
ci, pr-preview, release, validate-pr-title, workflow-sanity).
2026-06-07 14:23:12 -07:00

176 lines
5.7 KiB
Go

// Copyright (C) 2026, Hanzo AI Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// consensus.go — wires the kmsd ZAP secret-server to its consensus
// authority. Mirror of luxfi/kms cmd/kms/consensus.go but lives in
// hanzo/kms because the Hanzo daemon (cmd/kmsd) is the consumer.
//
// The ZAP server fails closed: with no validator/operator snapshot, the
// authorizer cannot be constructed and the ZAP server stays off. The
// HTTP path (JWT-gated, IAM-issued) is unaffected. The kms-operator is
// the component that drops the snapshot file onto the pod; when it has
// shipped, ZAP comes up. Until then HTTP carries the load.
package kms
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/kms/pkg/zapserver"
)
const (
envConsensusValidators = "KMS_CONSENSUS_VALIDATORS"
envConsensusOperators = "KMS_CONSENSUS_OPERATORS"
envConsensusFile = "KMS_CONSENSUS_FILE"
envConsensusTTL = "KMS_CONSENSUS_TTL"
defaultConsensusTTL = 30 * time.Second
)
// consensusSnapshot is the wire shape of the JSON file the kms-operator
// drops into the kmsd container. Identical to the env-var carriage so
// the operator can pick either delivery.
type consensusSnapshot struct {
Validators []string `json:"validators"`
Operators []string `json:"operators"`
}
// buildConsensusAuthorizer constructs the ConsensusAuthorizer wired at
// boot. Reads either KMS_CONSENSUS_FILE or KMS_CONSENSUS_VALIDATORS +
// KMS_CONSENSUS_OPERATORS. Returns (nil, nil) when no source is
// configured — the caller treats that as "ZAP server off, HTTP only"
// rather than a fatal. Returns (nil, err) when a source is configured
// but malformed; the caller refuses to start the ZAP server in that
// case (fail-closed on bad input).
func buildConsensusAuthorizer() (zapserver.ConsensusAuthorizer, error) {
validators, operators, err := loadConsensusSnapshot()
if err != nil {
return nil, err
}
if len(validators) == 0 && len(operators) == 0 {
// No snapshot configured. Caller turns off ZAP.
return nil, nil
}
if len(validators) == 0 {
return nil, errors.New("consensus validator authority is empty (refusing to fail open)")
}
if len(operators) == 0 {
return nil, errors.New("consensus operator authority is empty (refusing to fail open)")
}
ttl := defaultConsensusTTL
if v := strings.TrimSpace(os.Getenv(envConsensusTTL)); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return nil, fmt.Errorf("%s: %w", envConsensusTTL, err)
}
ttl = d
}
az, err := zapserver.NewInProcessAuthorizer(zapserver.InProcessAuthorizerConfig{
Validators: zapserver.NewStaticAuthorityProvider(validators),
Operator: zapserver.NewStaticAuthorityProvider(operators),
CacheTTL: ttl,
})
if err != nil {
return nil, err
}
// Probe both providers once so a malformed snapshot surfaces here
// (fatal path) rather than on the first inbound request.
if _, err := az.Authorize(context.Background(), zapserver.Identity{
NodeID: validators[0],
}, "self-test", zapserver.OpAuthGet); err != nil {
return nil, fmt.Errorf("authorizer self-test: %w", err)
}
return az, nil
}
// loadConsensusSnapshot returns the (validators, operators) NodeID
// sets, sourcing from KMS_CONSENSUS_FILE first then falling back to
// the two env vars.
func loadConsensusSnapshot() ([]ids.NodeID, []ids.NodeID, error) {
if path := strings.TrimSpace(os.Getenv(envConsensusFile)); path != "" {
data, err := os.ReadFile(path)
if err != nil {
// Missing file is not fatal at boot — operator hasn't
// shipped the snapshot yet. Return empty sets so caller
// disables ZAP rather than crashing.
if errors.Is(err, os.ErrNotExist) {
return nil, nil, nil
}
return nil, nil, fmt.Errorf("%s: %w", envConsensusFile, err)
}
var snap consensusSnapshot
if err := json.Unmarshal(data, &snap); err != nil {
return nil, nil, fmt.Errorf("%s: %w", envConsensusFile, err)
}
validators, err := parseConsensusNodeIDs(snap.Validators)
if err != nil {
return nil, nil, fmt.Errorf("%s validators: %w", envConsensusFile, err)
}
operators, err := parseConsensusNodeIDs(snap.Operators)
if err != nil {
return nil, nil, fmt.Errorf("%s operators: %w", envConsensusFile, err)
}
return validators, operators, nil
}
validators, err := parseConsensusNodeIDs(splitConsensusList(os.Getenv(envConsensusValidators)))
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", envConsensusValidators, err)
}
operators, err := parseConsensusNodeIDs(splitConsensusList(os.Getenv(envConsensusOperators)))
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", envConsensusOperators, err)
}
return validators, operators, nil
}
// splitConsensusList splits on newlines, commas, and whitespace. Empty
// elements are dropped. Operators pick whichever delimiter suits their
// templating.
func splitConsensusList(raw string) []string {
if raw == "" {
return nil
}
out := make([]string, 0, 8)
field := strings.Builder{}
for _, r := range raw {
if r == '\n' || r == ',' || r == ' ' || r == '\t' || r == '\r' {
if field.Len() > 0 {
out = append(out, field.String())
field.Reset()
}
continue
}
field.WriteRune(r)
}
if field.Len() > 0 {
out = append(out, field.String())
}
return out
}
// parseConsensusNodeIDs returns the parsed NodeID slice; any malformed
// entry is a hard failure (refuses to boot with a typo in the snapshot).
func parseConsensusNodeIDs(items []string) ([]ids.NodeID, error) {
out := make([]ids.NodeID, 0, len(items))
for _, s := range items {
s = strings.TrimSpace(s)
if s == "" {
continue
}
id, err := ids.NodeIDFromString(s)
if err != nil {
return nil, fmt.Errorf("parse %q: %w", s, err)
}
out = append(out, id)
}
return out, nil
}