mirror of
https://github.com/hanzoai/kms.git
synced 2026-10-17 11:18:16 +00:00
feat(custody): hand the serving side a store, not the database
Docker Build & Push (Reusable) / create-manifest (push) Blocked by required conditions
Docker Build & Push (Reusable) / tag-single-arch (push) Blocked by required conditions
Docker Build & Push (Reusable) / deploy (push) Blocked by required conditions
Docker Build & Push (Reusable) / sbom (push) Blocked by required conditions
Docker Build & Push (Reusable) / build-amd64 (push) Waiting to run
Docker Build & Push (Reusable) / build-arm64 (push) Skipped
Build kms / build (push) Waiting to run
Release / Build binaries (linux/amd64) (push) Waiting to run
Release / GitHub Release (push) Blocked by required conditions
Release / Multi-arch Docker (ghcr.io/hanzoai/kms) (push) Blocked by required conditions
Release / Notify universe (auto-bump pin) (push) Blocked by required conditions
Release / Build binaries (darwin/amd64) (push) Failing after 0s
Release / Build binaries (darwin/arm64) (push) Failing after 0s
Release / Build binaries (linux/arm64) (push) Failing after 0s
Docker Build & Push (Reusable) / create-manifest (push) Blocked by required conditions
Docker Build & Push (Reusable) / tag-single-arch (push) Blocked by required conditions
Docker Build & Push (Reusable) / deploy (push) Blocked by required conditions
Docker Build & Push (Reusable) / sbom (push) Blocked by required conditions
Docker Build & Push (Reusable) / build-amd64 (push) Waiting to run
Docker Build & Push (Reusable) / build-arm64 (push) Skipped
Build kms / build (push) Waiting to run
Release / Build binaries (linux/amd64) (push) Waiting to run
Release / GitHub Release (push) Blocked by required conditions
Release / Multi-arch Docker (ghcr.io/hanzoai/kms) (push) Blocked by required conditions
Release / Notify universe (auto-bump pin) (push) Blocked by required conditions
Release / Build binaries (darwin/amd64) (push) Failing after 0s
Release / Build binaries (darwin/arm64) (push) Failing after 0s
Release / Build binaries (linux/arm64) (push) Failing after 0s
The custody store had no way out of a running KMS: Embedded.db is unexported and there was no accessor, so cloud could not construct one at all. (*Embedded).Custody() closes that. It hands out the STORE rather than the database it lives in and never the master key: a caller that needs to enrol a node has no business reading the secret keyspace, and the key stays inside this module. It refuses when KMS_MASTER_KEY_B64 is absent or malformed, because nothing can be sealed without it and a custody surface that cannot seal must refuse rather than come up holding node wallets under something weaker. One store per Embedded, memoized on the struct rather than in a package variable. The store's lock is what serializes enrolment and rotation, so a second store over the same database would let two rotations interleave and leave a subject with two active identities. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -106,6 +106,11 @@ The org-scoped HTTP plane and its authorization live in cloud (`apps/kms`), the
|
||||
same place the secret plane moved to. `custody/mount_test.go` is the reference
|
||||
mount, written out in full and exercised against the real client.
|
||||
|
||||
`(*Embedded).Custody()` (`custody.go`) is the one way to reach the store: it hands
|
||||
out the store, never the database it lives in and never the master key, and
|
||||
refuses when `KMS_MASTER_KEY_B64` is absent or malformed — a custody surface that
|
||||
cannot seal must refuse rather than degrade.
|
||||
|
||||
- **Keyspaces are disjoint.** Record at `kms/nodes/{org}/{address}`, sealed key at
|
||||
`kms/custody/{org}/{address}`. Neither the secret keyspace nor an enumeration of
|
||||
node records can reach material; `node.Record` has no field for it.
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package kms
|
||||
|
||||
// custody.go — how a serving side reaches node identity custody.
|
||||
//
|
||||
// The custody store holds node wallets (see ./custody). This file is the only
|
||||
// way to obtain one from a running KMS, and it hands out the store rather than
|
||||
// the database it lives in: a caller that needs to enrol a node has no business
|
||||
// reading the secret keyspace, and it never sees the master key at all.
|
||||
//
|
||||
// Authorization is deliberately not here. This repository answers who signed a
|
||||
// token and derives no permission from one; the org-scoped plane that decides
|
||||
// who may enrol, sign, rebind or revoke lives in cloud (apps/kms), which passes
|
||||
// the org and subject it derived from the verified bearer into the calls below.
|
||||
// custody/mount_test.go is the reference for that mount.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
|
||||
"github.com/hanzoai/kms/custody"
|
||||
)
|
||||
|
||||
// Reasons the record master key is unusable. Both are refusals: no caller has a
|
||||
// plaintext fallback, so a surface that needs the key does not come up without
|
||||
// it.
|
||||
var (
|
||||
errNoMasterKey = errors.New("KMS_MASTER_KEY_B64 is not set")
|
||||
errBadMasterKey = errors.New("KMS_MASTER_KEY_B64 must decode to 32 raw bytes")
|
||||
)
|
||||
|
||||
// recordMasterKey resolves the 32-byte record master. It is what the ZAP
|
||||
// transport and node custody seal individual records under, and it is distinct
|
||||
// from the volume key (KMS_ENCRYPTION_KEY_B64) on purpose: a leaked volume key
|
||||
// alone must not yield a sealed record.
|
||||
func recordMasterKey() ([]byte, error) {
|
||||
b64 := envOr("KMS_MASTER_KEY_B64", "")
|
||||
if b64 == "" {
|
||||
return nil, errNoMasterKey
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, errBadMasterKey
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Custody returns the node identity store backed by this KMS.
|
||||
//
|
||||
// It fails when KMS_MASTER_KEY_B64 is absent or malformed, because nothing can
|
||||
// be sealed without it and a custody surface that cannot seal must refuse rather
|
||||
// than degrade. The same store is returned on every call, so the lock that
|
||||
// serializes enrolment and rotation is one lock.
|
||||
func (e *Embedded) Custody() (*custody.Store, error) {
|
||||
if e == nil || e.db == nil {
|
||||
return nil, errors.New("kms: no store")
|
||||
}
|
||||
e.custodyOnce.Do(func() {
|
||||
key, err := recordMasterKey()
|
||||
if err != nil {
|
||||
e.custodyErr = err
|
||||
return
|
||||
}
|
||||
e.custodyStore, e.custodyErr = custody.New(e.db, key)
|
||||
})
|
||||
return e.custodyStore, e.custodyErr
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package kms
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
badger "github.com/luxfi/zapdb"
|
||||
)
|
||||
|
||||
// embedded builds the minimum Embedded the custody accessor needs, without
|
||||
// booting a listener: Custody reads the database and the record master key and
|
||||
// nothing else.
|
||||
func embedded(t *testing.T) *Embedded {
|
||||
t.Helper()
|
||||
db, err := badger.Open(badger.DefaultOptions(filepath.Join(t.TempDir(), "kms")).WithLogger(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("open zapdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return &Embedded{db: db}
|
||||
}
|
||||
|
||||
// The serving side gets the store, never the database it lives in and never the
|
||||
// master key. One store per Embedded, because that store's lock is what
|
||||
// serializes enrolment and rotation.
|
||||
func TestCustodyHandsOutOneStore(t *testing.T) {
|
||||
t.Setenv("KMS_MASTER_KEY_B64", base64.StdEncoding.EncodeToString(make([]byte, 32)))
|
||||
e := embedded(t)
|
||||
|
||||
first, err := e.Custody()
|
||||
if err != nil {
|
||||
t.Fatalf("Custody: %v", err)
|
||||
}
|
||||
second, err := e.Custody()
|
||||
if err != nil {
|
||||
t.Fatalf("second Custody: %v", err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatal("Custody built a second store; two locks over one database can interleave a rotation")
|
||||
}
|
||||
}
|
||||
|
||||
// No key, no custody. A store that cannot seal must refuse rather than come up
|
||||
// and hold node wallets under something weaker.
|
||||
func TestCustodyRefusesWithoutTheRecordMasterKey(t *testing.T) {
|
||||
for name, key := range map[string]struct {
|
||||
value string
|
||||
want error
|
||||
}{
|
||||
"absent": {"", errNoMasterKey},
|
||||
"not base64": {"not-base64-at-all!!", errBadMasterKey},
|
||||
"too short": {base64.StdEncoding.EncodeToString(make([]byte, 16)), errBadMasterKey},
|
||||
"too long": {base64.StdEncoding.EncodeToString(make([]byte, 64)), errBadMasterKey},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Setenv("KMS_MASTER_KEY_B64", key.value)
|
||||
e := embedded(t)
|
||||
store, err := e.Custody()
|
||||
if !errors.Is(err, key.want) {
|
||||
t.Fatalf("Custody with a %s key: got %v, want %v", name, err, key.want)
|
||||
}
|
||||
if store != nil {
|
||||
t.Fatal("Custody returned a store it could not seal with")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustodyOnNothingIsRefused(t *testing.T) {
|
||||
t.Setenv("KMS_MASTER_KEY_B64", base64.StdEncoding.EncodeToString(make([]byte, 32)))
|
||||
var nilEmbedded *Embedded
|
||||
if _, err := nilEmbedded.Custody(); err == nil {
|
||||
t.Fatal("Custody on a nil Embedded returned a store")
|
||||
}
|
||||
if _, err := (&Embedded{}).Custody(); err == nil {
|
||||
t.Fatal("Custody with no database returned a store")
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,12 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
badger "github.com/luxfi/zapdb"
|
||||
|
||||
"github.com/hanzoai/kms/custody"
|
||||
"github.com/luxfi/kms/pkg/store"
|
||||
"github.com/luxfi/kms/pkg/zapserver"
|
||||
"github.com/luxfi/log"
|
||||
@@ -118,6 +120,13 @@ type Embedded struct {
|
||||
db *badger.DB
|
||||
replicator *badger.Replicator
|
||||
zapNode *zap.Node // nil when ZAP server disabled
|
||||
|
||||
// Node identity custody, built on first use. One store per Embedded, because
|
||||
// the store's lock is what serializes the read-modify-write paths; a second
|
||||
// store over the same database would let two rotations interleave.
|
||||
custodyOnce sync.Once
|
||||
custodyStore *custody.Store
|
||||
custodyErr error
|
||||
}
|
||||
|
||||
// Embed boots the Hanzo KMS server in-process and returns a handle.
|
||||
|
||||
Reference in New Issue
Block a user