mirror of
https://github.com/luxfi/kms.git
synced 2026-07-27 03:38:31 +00:00
feat(kms): consume per-authority path scopes in consensus snapshot
Activate least-privilege on the /v1/sdk enveloped secrets plane (task #53). The enforcement primitive (zapserver.ScopedAuthorityProvider) shipped in 4ab2015; this wires the kmsd CONSUMER side so a scoped snapshot actually confines a bound NodeID to its granted subtree. - consensusSnapshot gains an optional `scopes` block: per-authority (validator-read / operator-write) NodeID->path-prefix grants. Absent (nil) => flat authority + a boot WARN that a compromised member key can reach the entire secret tree. Present => NewScopedAuthorityProvider (un-granted members fail closed). The two authorities scope independently. - Inner scope maps carry NO omitempty so "scoped, zero grants" ({} => deny every member) survives the JSON round trip instead of failing OPEN to flat. - Malformed scope key (non-NodeID) refuses to boot (fail closed on bad input). Self-test tolerates a scoped deny (a Deny is not a construction failure; only a transient error is). - END-TO-END proof through verifyAndAuthorize (real signed ML-DSA-65 envelope, not just unit Authorize): in-scope read of own secret allowed; sibling / straddle (commerce vs commerce-evil) / parent / no-grant denied; write confined by the tighter operator scope, independent of the read scope. Flat back-compat stays green. Back-compat: env-var carriage and scope-less snapshots keep the prior role-based posture. Inert until the operator emits scopes. Refs #53 Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
+125
-22
@@ -21,6 +21,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -75,9 +76,48 @@ func buildNonceLedger() (zapserver.NonceLedger, error) {
|
||||
// consensusSnapshot is the wire shape of the JSON file the operator
|
||||
// drops into the kmsd container. Same shape as the env vars (one
|
||||
// authority per slice) so the operator can pick either delivery.
|
||||
//
|
||||
// Scopes is the OPTIONAL least-privilege overlay. When present it confines
|
||||
// each member NodeID of an authority to a path prefix, so a single
|
||||
// compromised service key cannot reach the entire secret tree. Absent (nil)
|
||||
// leaves both authorities flat (unconfined) — the pre-existing role-based
|
||||
// posture and the only posture the env-var carriage can express. The scope
|
||||
// is AUTHORITY-side data keyed by the cryptographically-bound NodeID; it is
|
||||
// never taken from the caller's self-declared envelope path.
|
||||
type consensusSnapshot struct {
|
||||
Validators []string `json:"validators"`
|
||||
Operators []string `json:"operators"`
|
||||
Validators []string `json:"validators"`
|
||||
Operators []string `json:"operators"`
|
||||
Scopes *consensusScopes `json:"scopes,omitempty"`
|
||||
}
|
||||
|
||||
// consensusScopes carries the per-authority NodeID→path-prefix grants. A
|
||||
// non-nil authority map opts that authority into least-privilege scoping:
|
||||
// a member absent from the map is DENIED every path (explicit grant
|
||||
// required — fail closed). A nil authority map leaves that authority flat
|
||||
// (every member unconfined). The two authorities are independent: the read
|
||||
// (validator) authority can be scoped while the write (operator) authority
|
||||
// stays flat, or vice-versa.
|
||||
//
|
||||
// The map fields deliberately carry NO omitempty: a present-but-empty map
|
||||
// ("scoped, zero grants" → deny every member) must survive the JSON round
|
||||
// trip as `{}` and NOT be silently dropped to null/nil (which would fail
|
||||
// OPEN to flat). nil marshals to `null` (flat); `{}` stays `{}` (scoped).
|
||||
type consensusScopes struct {
|
||||
Validators map[string]string `json:"validators"`
|
||||
Operators map[string]string `json:"operators"`
|
||||
}
|
||||
|
||||
// parsedSnapshot is the fully-parsed authority data: the two NodeID member
|
||||
// sets plus the optional per-authority scope maps, keyed by the parsed
|
||||
// NodeID so the provider constructors can consume them directly. A nil
|
||||
// scope map means the authority is flat (unconfined); a non-nil map (even
|
||||
// empty) means the authority is scoped and fail-closed for un-granted
|
||||
// members.
|
||||
type parsedSnapshot struct {
|
||||
validators []ids.NodeID
|
||||
operators []ids.NodeID
|
||||
validatorScopes map[ids.NodeID]string
|
||||
operatorScopes map[ids.NodeID]string
|
||||
}
|
||||
|
||||
// buildConsensusAuthorizer constructs the ConsensusAuthorizer wired
|
||||
@@ -85,14 +125,14 @@ type consensusSnapshot struct {
|
||||
// vars; refuses to boot if neither is present, since the ZAP server
|
||||
// is fail-closed by construction.
|
||||
func buildConsensusAuthorizer() (zapserver.ConsensusAuthorizer, error) {
|
||||
validators, operators, err := loadConsensusSnapshot()
|
||||
snap, err := loadConsensusSnapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(validators) == 0 {
|
||||
if len(snap.validators) == 0 {
|
||||
return nil, errors.New("consensus validator authority is empty (refusing to boot fail-open)")
|
||||
}
|
||||
if len(operators) == 0 {
|
||||
if len(snap.operators) == 0 {
|
||||
return nil, errors.New("consensus operator authority is empty (refusing to boot fail-open)")
|
||||
}
|
||||
ttl := defaultConsensusTTL
|
||||
@@ -104,55 +144,118 @@ func buildConsensusAuthorizer() (zapserver.ConsensusAuthorizer, error) {
|
||||
ttl = d
|
||||
}
|
||||
az, err := zapserver.NewInProcessAuthorizer(zapserver.InProcessAuthorizerConfig{
|
||||
Validators: zapserver.NewStaticAuthorityProvider(validators),
|
||||
Operator: zapserver.NewStaticAuthorityProvider(operators),
|
||||
Validators: newAuthorityProvider(snap.validators, snap.validatorScopes, "validator/read"),
|
||||
Operator: newAuthorityProvider(snap.operators, snap.operatorScopes, "operator/write"),
|
||||
CacheTTL: ttl,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Probe both providers once so a malformed snapshot surfaces here
|
||||
// (in the fatal path) rather than on the first inbound request.
|
||||
// Probe the authorizer once so a malformed snapshot surfaces here (in
|
||||
// the fatal path) rather than on the first inbound request. A scoped
|
||||
// authority may legitimately DENY the probe path — that is not a
|
||||
// construction failure, so we only reject a non-nil transient error
|
||||
// (nil map, provider dial failure), never a clean Deny.
|
||||
if _, err := az.Authorize(context.Background(), zapserver.Identity{
|
||||
NodeID: validators[0],
|
||||
NodeID: snap.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
|
||||
// KMS_CONSENSUS_VALIDATORS + KMS_CONSENSUS_OPERATORS env vars.
|
||||
func loadConsensusSnapshot() ([]ids.NodeID, []ids.NodeID, error) {
|
||||
// newAuthorityProvider builds the AuthorityProvider for one authority. A
|
||||
// nil scope map yields a flat (unconfined) provider and logs a WARN — a
|
||||
// compromised member key of a flat authority can reach the entire secret
|
||||
// tree, so an unscoped production authority is a blast-radius liability the
|
||||
// operator should close by emitting scopes.<authority>. A non-nil scope map
|
||||
// yields a least-privilege scoped provider (members confined to their
|
||||
// granted prefix; un-granted members fail closed).
|
||||
func newAuthorityProvider(members []ids.NodeID, scopes map[ids.NodeID]string, name string) zapserver.AuthorityProvider {
|
||||
if scopes == nil {
|
||||
log.Printf("kms: WARNING: consensus %s authority is UNCONFINED (%d members, no scopes) — "+
|
||||
"a compromised member key can reach the ENTIRE secret tree; emit scopes to enable least-privilege",
|
||||
name, len(members))
|
||||
return zapserver.NewStaticAuthorityProvider(members)
|
||||
}
|
||||
log.Printf("kms: consensus %s authority scoped (%d members, %d grants) — least-privilege enforced",
|
||||
name, len(members), len(scopes))
|
||||
return zapserver.NewScopedAuthorityProvider(members, scopes)
|
||||
}
|
||||
|
||||
// loadConsensusSnapshot returns the fully-parsed authority data
|
||||
// (validators + operators NodeID sets, plus optional per-authority scope
|
||||
// maps), sourcing from KMS_CONSENSUS_FILE first then falling back to
|
||||
// KMS_CONSENSUS_VALIDATORS + KMS_CONSENSUS_OPERATORS env vars. The env-var
|
||||
// carriage has no scope channel, so it always yields flat authorities.
|
||||
func loadConsensusSnapshot() (parsedSnapshot, error) {
|
||||
if path := strings.TrimSpace(os.Getenv(envFile)); path != "" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", envFile, err)
|
||||
return parsedSnapshot{}, fmt.Errorf("%s: %w", envFile, err)
|
||||
}
|
||||
var snap consensusSnapshot
|
||||
if err := json.Unmarshal(data, &snap); err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", envFile, err)
|
||||
return parsedSnapshot{}, fmt.Errorf("%s: %w", envFile, err)
|
||||
}
|
||||
validators, err := parseNodeIDs(snap.Validators)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s validators: %w", envFile, err)
|
||||
return parsedSnapshot{}, fmt.Errorf("%s validators: %w", envFile, err)
|
||||
}
|
||||
operators, err := parseNodeIDs(snap.Operators)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s operators: %w", envFile, err)
|
||||
return parsedSnapshot{}, fmt.Errorf("%s operators: %w", envFile, err)
|
||||
}
|
||||
return validators, operators, nil
|
||||
ps := parsedSnapshot{validators: validators, operators: operators}
|
||||
if snap.Scopes != nil {
|
||||
if snap.Scopes.Validators != nil {
|
||||
ps.validatorScopes, err = parseScopeMap(snap.Scopes.Validators)
|
||||
if err != nil {
|
||||
return parsedSnapshot{}, fmt.Errorf("%s scopes.validators: %w", envFile, err)
|
||||
}
|
||||
}
|
||||
if snap.Scopes.Operators != nil {
|
||||
ps.operatorScopes, err = parseScopeMap(snap.Scopes.Operators)
|
||||
if err != nil {
|
||||
return parsedSnapshot{}, fmt.Errorf("%s scopes.operators: %w", envFile, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
validators, err := parseNodeIDs(splitLines(os.Getenv(envValidators)))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", envValidators, err)
|
||||
return parsedSnapshot{}, fmt.Errorf("%s: %w", envValidators, err)
|
||||
}
|
||||
operators, err := parseNodeIDs(splitLines(os.Getenv(envOperators)))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", envOperators, err)
|
||||
return parsedSnapshot{}, fmt.Errorf("%s: %w", envOperators, err)
|
||||
}
|
||||
return validators, operators, nil
|
||||
return parsedSnapshot{validators: validators, operators: operators}, nil
|
||||
}
|
||||
|
||||
// parseScopeMap parses a NodeID-string→path-prefix map into a
|
||||
// NodeID→prefix map. Each key MUST be a valid NodeID (a typo is a hard
|
||||
// failure — refusing to boot with a malformed scope grant, matching
|
||||
// parseNodeIDs). The prefix value is normalized (surrounding whitespace and
|
||||
// slashes trimmed) so scope containment compares canonical "/"-joined
|
||||
// paths; an empty prefix means the member is unconfined (root). The result
|
||||
// is non-nil even for an empty input so the caller distinguishes "scoped,
|
||||
// zero grants" (fail-closed for every member) from "flat, no scopes" (nil).
|
||||
func parseScopeMap(raw map[string]string) (map[ids.NodeID]string, error) {
|
||||
out := make(map[ids.NodeID]string, len(raw))
|
||||
for k, v := range raw {
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
id, err := ids.NodeIDFromString(k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scope key %q: %w", k, err)
|
||||
}
|
||||
out[id] = strings.Trim(strings.TrimSpace(v), "/")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// splitLines splits on newlines, commas, and whitespace. Empty
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// consensus_scope_test.go — coverage for the kmsd's consumption of the
|
||||
// least-privilege `scopes` overlay in the consensus snapshot
|
||||
// (KMS_CONSENSUS_FILE). Proves the boot-time wiring turns a scoped snapshot
|
||||
// into a scoped authorizer (straddle / parent / sibling / no-grant all
|
||||
// deny), that a flat snapshot stays unconfined (back-compat), and that a
|
||||
// malformed scope grant refuses to boot (fail closed on bad input).
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/kms/pkg/zapserver"
|
||||
)
|
||||
|
||||
// scopeTestNode returns a deterministic non-zero NodeID whose String()
|
||||
// round-trips through ids.NodeIDFromString (the parse path the snapshot
|
||||
// loader uses).
|
||||
func scopeTestNode(seed byte) ids.NodeID {
|
||||
var n ids.NodeID
|
||||
for i := range n {
|
||||
n[i] = seed
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// writeSnapshot marshals snap to a temp file and points KMS_CONSENSUS_FILE
|
||||
// at it, clearing the env-var carriage so the file path is authoritative.
|
||||
func writeSnapshot(t *testing.T, snap consensusSnapshot) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal snapshot: %v", err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "consensus-authority.json")
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("write snapshot: %v", err)
|
||||
}
|
||||
t.Setenv(envValidators, "")
|
||||
t.Setenv(envOperators, "")
|
||||
t.Setenv(envTTL, "")
|
||||
t.Setenv(envFile, path)
|
||||
}
|
||||
|
||||
func mustAuthz(t *testing.T) zapserver.ConsensusAuthorizer {
|
||||
t.Helper()
|
||||
az, err := buildConsensusAuthorizer()
|
||||
if err != nil {
|
||||
t.Fatalf("buildConsensusAuthorizer: %v", err)
|
||||
}
|
||||
return az
|
||||
}
|
||||
|
||||
func allow(t *testing.T, az zapserver.ConsensusAuthorizer, node ids.NodeID, path string, op zapserver.Op) {
|
||||
t.Helper()
|
||||
d, err := az.Authorize(context.Background(), zapserver.Identity{NodeID: node}, path, op)
|
||||
if err != nil {
|
||||
t.Fatalf("Authorize(%s, %q) unexpected err: %v", op, path, err)
|
||||
}
|
||||
if !d.Allow {
|
||||
t.Fatalf("Authorize(%s, %q) must ALLOW, got deny(%s)", op, path, d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func deny(t *testing.T, az zapserver.ConsensusAuthorizer, node ids.NodeID, path string, op zapserver.Op) {
|
||||
t.Helper()
|
||||
d, err := az.Authorize(context.Background(), zapserver.Identity{NodeID: node}, path, op)
|
||||
if err != nil {
|
||||
t.Fatalf("Authorize(%s, %q) unexpected err: %v", op, path, err)
|
||||
}
|
||||
if d.Allow {
|
||||
t.Fatalf("Authorize(%s, %q) must DENY (scope), got allow", op, path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsensusScopes_ScopedSnapshot_Enforced — a snapshot carrying a
|
||||
// scopes.validators grant confines the reader to its subtree; a co-member
|
||||
// with no grant fails closed.
|
||||
func TestConsensusScopes_ScopedSnapshot_Enforced(t *testing.T) {
|
||||
commerce := scopeTestNode(0x11)
|
||||
kms := scopeTestNode(0x22)
|
||||
|
||||
writeSnapshot(t, consensusSnapshot{
|
||||
Validators: []string{commerce.String(), kms.String()},
|
||||
Operators: []string{commerce.String()},
|
||||
Scopes: &consensusScopes{
|
||||
Validators: map[string]string{commerce.String(): "hanzo/commerce"},
|
||||
// kms is a member but carries no grant → fail closed.
|
||||
Operators: map[string]string{commerce.String(): "hanzo/commerce"},
|
||||
},
|
||||
})
|
||||
az := mustAuthz(t)
|
||||
|
||||
// commerce reads its own subtree.
|
||||
allow(t, az, commerce, "hanzo/commerce", zapserver.OpAuthGet)
|
||||
allow(t, az, commerce, "hanzo/commerce/db", zapserver.OpAuthGet)
|
||||
|
||||
// commerce cannot escape its subtree.
|
||||
deny(t, az, commerce, "hanzo", zapserver.OpAuthGet) // parent
|
||||
deny(t, az, commerce, "hanzo/kms", zapserver.OpAuthGet) // sibling
|
||||
deny(t, az, commerce, "hanzo/commerce-evil", zapserver.OpAuthGet) // straddle
|
||||
deny(t, az, commerce, "hanzo/commerce-evil/x", zapserver.OpAuthGet)
|
||||
|
||||
// kms is a scoped member with NO grant → denied everything (fail closed).
|
||||
deny(t, az, kms, "hanzo/kms", zapserver.OpAuthGet)
|
||||
deny(t, az, kms, "hanzo/commerce", zapserver.OpAuthGet)
|
||||
|
||||
// In-scope write for commerce (member of the operator authority too).
|
||||
allow(t, az, commerce, "hanzo/commerce/api-key", zapserver.OpAuthPut)
|
||||
deny(t, az, commerce, "hanzo/kms", zapserver.OpAuthPut)
|
||||
}
|
||||
|
||||
// TestConsensusScopes_FlatSnapshot_Unconfined — a snapshot WITHOUT a scopes
|
||||
// block leaves both authorities flat: every member reads/writes any path
|
||||
// (the pre-existing role-based posture; back-compat).
|
||||
func TestConsensusScopes_FlatSnapshot_Unconfined(t *testing.T) {
|
||||
a := scopeTestNode(0x33)
|
||||
|
||||
writeSnapshot(t, consensusSnapshot{
|
||||
Validators: []string{a.String()},
|
||||
Operators: []string{a.String()},
|
||||
// No Scopes.
|
||||
})
|
||||
az := mustAuthz(t)
|
||||
|
||||
for _, p := range []string{"hanzo/commerce", "lux/bridge", "anything/at/all"} {
|
||||
allow(t, az, a, p, zapserver.OpAuthGet)
|
||||
allow(t, az, a, p, zapserver.OpAuthPut)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsensusScopes_PartialScope_ReadScopedWriteFlat — the two authorities
|
||||
// scope independently: scopes.validators present (read confined) while
|
||||
// scopes.operators absent (write flat).
|
||||
func TestConsensusScopes_PartialScope_ReadScopedWriteFlat(t *testing.T) {
|
||||
a := scopeTestNode(0x44)
|
||||
|
||||
writeSnapshot(t, consensusSnapshot{
|
||||
Validators: []string{a.String()},
|
||||
Operators: []string{a.String()},
|
||||
Scopes: &consensusScopes{
|
||||
Validators: map[string]string{a.String(): "hanzo/commerce"},
|
||||
// Operators nil → flat write authority.
|
||||
},
|
||||
})
|
||||
az := mustAuthz(t)
|
||||
|
||||
// Read confined.
|
||||
allow(t, az, a, "hanzo/commerce/x", zapserver.OpAuthGet)
|
||||
deny(t, az, a, "hanzo/kms", zapserver.OpAuthGet)
|
||||
|
||||
// Write flat (unconfined) — a is in the operator set with no operator
|
||||
// scope, so the write scope check is a no-op. It must still pass the
|
||||
// validator gate+scope first, so pick an in-read-scope path.
|
||||
allow(t, az, a, "hanzo/commerce/x", zapserver.OpAuthPut)
|
||||
}
|
||||
|
||||
// TestConsensusScopes_MalformedScopeKey_RefusesToBoot — a scope grant keyed
|
||||
// by a non-NodeID string is a hard failure. The kmsd refuses to construct
|
||||
// the authorizer rather than silently drop the grant (which would fail-open
|
||||
// a member to unconfined or drop it entirely).
|
||||
func TestConsensusScopes_MalformedScopeKey_RefusesToBoot(t *testing.T) {
|
||||
a := scopeTestNode(0x55)
|
||||
|
||||
writeSnapshot(t, consensusSnapshot{
|
||||
Validators: []string{a.String()},
|
||||
Operators: []string{a.String()},
|
||||
Scopes: &consensusScopes{
|
||||
Validators: map[string]string{"not-a-valid-nodeid": "hanzo/commerce"},
|
||||
},
|
||||
})
|
||||
if _, err := buildConsensusAuthorizer(); err == nil {
|
||||
t.Fatalf("malformed scope key must refuse to boot, got nil error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsensusScopes_EmptyScopeMap_FailsClosed — a scopes.validators that
|
||||
// is present but empty opts the authority into scoping with ZERO grants:
|
||||
// every member is denied (fail closed), NOT defaulted to unconfined. Guards
|
||||
// against a "present but empty" snapshot silently disabling confinement.
|
||||
func TestConsensusScopes_EmptyScopeMap_FailsClosed(t *testing.T) {
|
||||
a := scopeTestNode(0x66)
|
||||
|
||||
writeSnapshot(t, consensusSnapshot{
|
||||
Validators: []string{a.String()},
|
||||
Operators: []string{a.String()},
|
||||
Scopes: &consensusScopes{
|
||||
Validators: map[string]string{}, // present, empty → scoped, no grants
|
||||
},
|
||||
})
|
||||
az := mustAuthz(t)
|
||||
deny(t, az, a, "hanzo/commerce", zapserver.OpAuthGet)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// path_scope_e2e_test.go — END-TO-END proof that authority-anchored path
|
||||
// scoping holds through the FULL request path (verifyAndAuthorize: parse
|
||||
// envelope → verify ML-DSA-65 signature + binding + freshness + replay →
|
||||
// extract addressed path → consensus authorize), not just the unit
|
||||
// Authorize entry point.
|
||||
//
|
||||
// This is the RED re-review gate for task #53 (KMS path-scope activation):
|
||||
// a scoped authority snapshot must confine a cryptographically-bound NodeID
|
||||
// to its granted subtree even when the caller signs a perfectly valid
|
||||
// envelope addressing a DIFFERENT subtree. The scope is authority-side data
|
||||
// keyed by the bound NodeID; the caller cannot widen it by setting the
|
||||
// envelope's inner `path` to anything it likes — that path is exactly what
|
||||
// gets confinement-checked.
|
||||
//
|
||||
// Coverage (all driven with a real signed envelope):
|
||||
//
|
||||
// - in-scope read of the service's OWN secret → allowed end-to-end
|
||||
// (verifyAndAuthorize + handleGet returns the sealed value);
|
||||
// - sibling subtree, straddling prefix (commerce vs commerce-evil),
|
||||
// parent escape → denied;
|
||||
// - a scoped member with NO grant → denied every path (fail closed);
|
||||
// - write confinement by the OPERATOR authority's (tighter) scope,
|
||||
// isolated from the read (validator) scope;
|
||||
// - flat (unscoped) authority stays unconfined (back-compat — covered
|
||||
// additionally by server_auth_test.go's role tests).
|
||||
|
||||
package zapserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/kms/pkg/store"
|
||||
"github.com/luxfi/log"
|
||||
badger "github.com/luxfi/zapdb"
|
||||
)
|
||||
|
||||
// newScopedTestServer mirrors newTestServer but wires per-authority scope
|
||||
// maps. A nil scope map yields a flat (unconfined) provider; a non-nil map
|
||||
// yields a least-privilege scoped provider (un-granted members fail closed).
|
||||
func newScopedTestServer(t *testing.T, validators, operators []ids.NodeID, valScopes, opScopes map[ids.NodeID]string) *Server {
|
||||
t.Helper()
|
||||
opts := badger.DefaultOptions("").WithInMemory(true)
|
||||
db, err := badger.Open(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("open zapdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
mk := make([]byte, 32)
|
||||
if _, err := rand.Read(mk); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
authz, err := NewInProcessAuthorizer(InProcessAuthorizerConfig{
|
||||
Validators: scopedOrFlat(validators, valScopes),
|
||||
Operator: scopedOrFlat(operators, opScopes),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("authorizer: %v", err)
|
||||
}
|
||||
return New(Config{
|
||||
Store: store.NewSecretStore(db),
|
||||
MasterKey: mk,
|
||||
Authorizer: authz,
|
||||
Logger: log.NewNoOpLogger(),
|
||||
Now: func() time.Time { return time.Unix(1_717_200_000, 0) },
|
||||
})
|
||||
}
|
||||
|
||||
// scopedOrFlat picks the provider constructor matching the intended posture:
|
||||
// nil scopes → flat, non-nil → scoped.
|
||||
func scopedOrFlat(members []ids.NodeID, scopes map[ids.NodeID]string) AuthorityProvider {
|
||||
if scopes == nil {
|
||||
return NewStaticAuthorityProvider(members)
|
||||
}
|
||||
return NewScopedAuthorityProvider(members, scopes)
|
||||
}
|
||||
|
||||
// TestScopedE2E_ReadOwnSubtree_Allowed proves a scoped validator reads its
|
||||
// OWN secret end-to-end: signed envelope → verify → authorize → handleGet
|
||||
// returns the sealed plaintext. This is the "service reads its own subtree"
|
||||
// happy path the least-privilege boundary must preserve.
|
||||
func TestScopedE2E_ReadOwnSubtree_Allowed(t *testing.T) {
|
||||
ident := newIdentity(t, "hanzo/commerce")
|
||||
defer ident.Wipe()
|
||||
|
||||
s := newScopedTestServer(t,
|
||||
[]ids.NodeID{ident.NodeID}, nil,
|
||||
map[ids.NodeID]string{ident.NodeID: "hanzo/commerce"}, nil,
|
||||
)
|
||||
seed(t, s, "hanzo/commerce", "api-key", "prod", "sk_live_own")
|
||||
now := time.Unix(1_717_200_000, 0)
|
||||
|
||||
// Scope root itself.
|
||||
raw := signedEnvelopeBytes(t, ident, OpSecretGet, buildInner(t, getReq{
|
||||
Path: "hanzo/commerce", Name: "api-key", Env: "prod",
|
||||
}), now, "scope-root-nonce")
|
||||
vident, payload, err := s.verifyAndAuthorize(context.Background(), raw, OpSecretGet)
|
||||
if err != nil {
|
||||
t.Fatalf("in-scope read must be allowed end-to-end: %v", err)
|
||||
}
|
||||
st, body, herr := s.handleGet(context.Background(), vident, payload)
|
||||
if herr != nil || st != statusOK {
|
||||
t.Fatalf("handleGet own secret: status=0x%02X err=%v", st, herr)
|
||||
}
|
||||
var gr getResp
|
||||
if err := json.Unmarshal(body, &gr); err != nil {
|
||||
t.Fatalf("decode getResp: %v", err)
|
||||
}
|
||||
if dec, _ := base64.StdEncoding.DecodeString(gr.Value); string(dec) != "sk_live_own" {
|
||||
t.Fatalf("own-secret value mismatch: got %q want sk_live_own", string(dec))
|
||||
}
|
||||
|
||||
// A descendant path is inside the scope too.
|
||||
rawDesc := signedEnvelopeBytes(t, ident, OpSecretGet, buildInner(t, getReq{
|
||||
Path: "hanzo/commerce/db", Name: "x", Env: "prod",
|
||||
}), now, "scope-desc-nonce")
|
||||
if _, _, err := s.verifyAndAuthorize(context.Background(), rawDesc, OpSecretGet); err != nil {
|
||||
t.Fatalf("descendant read must be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopedE2E_ReadOutsideScope_Denied drives the three escape shapes RED
|
||||
// cares about through the full envelope path: sibling subtree, straddling
|
||||
// prefix (commerce vs commerce-evil), and parent escape. Each is a
|
||||
// signature-valid envelope from the correct NodeID — only the scope check
|
||||
// stops it.
|
||||
func TestScopedE2E_ReadOutsideScope_Denied(t *testing.T) {
|
||||
ident := newIdentity(t, "hanzo/commerce")
|
||||
defer ident.Wipe()
|
||||
|
||||
s := newScopedTestServer(t,
|
||||
[]ids.NodeID{ident.NodeID}, nil,
|
||||
map[ids.NodeID]string{ident.NodeID: "hanzo/commerce"}, nil,
|
||||
)
|
||||
now := time.Unix(1_717_200_000, 0)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"sibling-subtree", "hanzo/kms"},
|
||||
{"sibling-secret", "hanzo/kms/master"},
|
||||
{"straddling-prefix", "hanzo/commerce-evil"},
|
||||
{"straddling-secret", "hanzo/commerce-evil/exfil"},
|
||||
{"parent-escape", "hanzo"},
|
||||
{"unrelated-root", "lux/bridge"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
raw := signedEnvelopeBytes(t, ident, OpSecretGet, buildInner(t, getReq{
|
||||
Path: c.path, Name: "api-key", Env: "prod",
|
||||
}), now, "outside-"+c.name+"-nonce")
|
||||
if _, _, err := s.verifyAndAuthorize(context.Background(), raw, OpSecretGet); err == nil {
|
||||
t.Fatalf("read of %q (outside scope hanzo/commerce) MUST be denied end-to-end, got allow", c.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopedE2E_ScopedMemberNoGrant_Denied — opting the validator authority
|
||||
// into scoping makes an explicit grant mandatory. A NodeID that is a MEMBER
|
||||
// but carries no scope entry is denied every path (fail closed), proven
|
||||
// through the full envelope path.
|
||||
func TestScopedE2E_ScopedMemberNoGrant_Denied(t *testing.T) {
|
||||
ident := newIdentity(t, "hanzo/commerce")
|
||||
defer ident.Wipe()
|
||||
|
||||
// Non-nil (scoped) validator authority, but the grant map is empty:
|
||||
// ident is a member with NO grant.
|
||||
s := newScopedTestServer(t,
|
||||
[]ids.NodeID{ident.NodeID}, nil,
|
||||
map[ids.NodeID]string{}, nil,
|
||||
)
|
||||
now := time.Unix(1_717_200_000, 0)
|
||||
|
||||
raw := signedEnvelopeBytes(t, ident, OpSecretGet, buildInner(t, getReq{
|
||||
Path: "hanzo/commerce", Name: "api-key", Env: "prod",
|
||||
}), now, "no-grant-nonce")
|
||||
if _, _, err := s.verifyAndAuthorize(context.Background(), raw, OpSecretGet); err == nil {
|
||||
t.Fatalf("scoped member with no grant MUST be denied (fail closed), got allow")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopedE2E_WriteConfinedByOperatorScope isolates the OPERATOR (write)
|
||||
// authority's scope from the validator (read) scope. The identity may READ
|
||||
// broadly across "hanzo" but WRITE only within "hanzo/commerce": a Put to
|
||||
// "hanzo/kms/x" passes the validator gate+scope yet is stopped by the
|
||||
// tighter operator scope. This proves the write grant is independently
|
||||
// enforced end-to-end.
|
||||
func TestScopedE2E_WriteConfinedByOperatorScope(t *testing.T) {
|
||||
ident := newIdentity(t, "hanzo/commerce")
|
||||
defer ident.Wipe()
|
||||
|
||||
s := newScopedTestServer(t,
|
||||
[]ids.NodeID{ident.NodeID}, []ids.NodeID{ident.NodeID},
|
||||
map[ids.NodeID]string{ident.NodeID: "hanzo"}, // broad READ
|
||||
map[ids.NodeID]string{ident.NodeID: "hanzo/commerce"}, // tight WRITE
|
||||
)
|
||||
now := time.Unix(1_717_200_000, 0)
|
||||
|
||||
// In-scope write.
|
||||
putIn := signedEnvelopeBytes(t, ident, OpSecretPut, buildInner(t, putReq{
|
||||
Path: "hanzo/commerce", Name: "rotation-key", Env: "prod",
|
||||
Value: base64.StdEncoding.EncodeToString([]byte("newSecret")),
|
||||
}), now, "write-in-nonce")
|
||||
vident, payload, err := s.verifyAndAuthorize(context.Background(), putIn, OpSecretPut)
|
||||
if err != nil {
|
||||
t.Fatalf("in-scope write must be allowed: %v", err)
|
||||
}
|
||||
if st, _, herr := s.handlePut(context.Background(), vident, payload); herr != nil || st != statusOK {
|
||||
t.Fatalf("handlePut in-scope: status=0x%02X err=%v", st, herr)
|
||||
}
|
||||
|
||||
// Out-of-write-scope Put (still inside the broad READ scope "hanzo",
|
||||
// so the denial is unambiguously the OPERATOR scope).
|
||||
putOut := signedEnvelopeBytes(t, ident, OpSecretPut, buildInner(t, putReq{
|
||||
Path: "hanzo/kms", Name: "master", Env: "prod",
|
||||
Value: base64.StdEncoding.EncodeToString([]byte("evil")),
|
||||
}), now, "write-out-nonce")
|
||||
if _, _, err := s.verifyAndAuthorize(context.Background(), putOut, OpSecretPut); err == nil {
|
||||
t.Fatalf("write to hanzo/kms (inside read scope, outside write scope) MUST be denied, got allow")
|
||||
}
|
||||
|
||||
// A read of the same out-of-write-scope path IS allowed — the read scope
|
||||
// is broader. This pins that read and write scopes are enforced
|
||||
// independently, not conflated.
|
||||
readOut := signedEnvelopeBytes(t, ident, OpSecretGet, buildInner(t, getReq{
|
||||
Path: "hanzo/kms", Name: "master", Env: "prod",
|
||||
}), now, "read-broad-nonce")
|
||||
if _, _, err := s.verifyAndAuthorize(context.Background(), readOut, OpSecretGet); err != nil {
|
||||
t.Fatalf("read within the broad read scope must be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user