Files
mesh-controller/internal/inventory/secrets_test.go
T
jschoubben c37d368f65 A module may need a secret of its own, and the provisioner watches
Two things, both found by trying to write a real postgres module and
discovering it could not be said.

A database has a superuser password, a broker an administrator, a
registry an account. None of them is *for* anybody — they are not the
credential a consumer is given, and the mechanism that hands those out
has a consumer in the middle of it. So a module may declare what it needs
and where to put it, and the mesh generates one per node, seals it, and
reads it no more than it reads any other.

Per node, deliberately: a module running on three machines has three
passwords. One in the manifest instead would put the same secret on every
machine that ever runs it, in a file anybody can read, for ever. Made
once and kept, or a running database would be handed a password it was
not started with; remade when the machine's sealing key changes, like
everything else sealed here.

A need declared and not made is refused rather than skipped, because a
module whose own credential is silently absent starts, fails to
authenticate, and the reason is three layers from the machine reporting
it.

And the provisioner can watch. That is what lets it be a module rather
than a binary somebody places: run once, it needs invoking after every
declaration by a timer or a unit wired to a file; watching, it is an
ordinary long-running service the host already supervises. It polls
rather than watching the filesystem, because the host writes atomically —
the file is replaced, so a watch on the path stops seeing anything after
the first replacement, and a watcher that silently stops working is worse
than a poll. Credentials are compared by digest and never held: this runs
for as long as the machine is up.
2026-08-30 18:22:05 +02:00

328 lines
10 KiB
Go

package inventory
import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"github.com/novox/mesh-control/internal/catalogue"
"strings"
"testing"
"golang.org/x/crypto/nacl/box"
)
// aSealingKey is a node's key, keeping the private half so a test can open what was sealed — the
// only assertion that actually distinguishes "the right blob" from "a blob".
func aSealingKey(t *testing.T) (string, func(string) ([]byte, bool)) {
t.Helper()
k, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
var pub, priv [32]byte
copy(pub[:], k.PublicKey().Bytes())
copy(priv[:], k.Bytes())
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()),
func(sealed string) ([]byte, bool) {
blob, err := base64.StdEncoding.DecodeString(sealed)
if err != nil {
return nil, false
}
return box.OpenAnonymous(nil, blob, &pub, &priv)
}
}
func twoNodesWithKeys(t *testing.T) (*Inventory, context.Context) {
t.Helper()
inv := fresh(t)
ctx := context.Background()
for _, n := range []string{"consumer", "provider"} {
node, err := inv.AddNode(ctx, n)
if err != nil {
t.Fatal(err)
}
key, _ := aSealingKey(t)
if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil {
t.Fatal(err)
}
}
return inv, ctx
}
func TestASecretIsMadeOnceAndKept(t *testing.T) {
// Regenerating on every declaration would restart both ends on every push, and — worse — the
// password a provider was told to create would never be the one its consumer was given.
inv, ctx := twoNodesWithKeys(t)
first, err := inv.SecretFor(ctx, "database", "consumer", "provider")
if err != nil {
t.Fatal(err)
}
second, err := inv.SecretFor(ctx, "database", "consumer", "provider")
if err != nil {
t.Fatal(err)
}
if first.ForConsumer != second.ForConsumer || first.ForProvider != second.ForProvider {
t.Fatal("asking twice produced two different credentials")
}
}
func TestTheStoredSecretIsNotTheSecret(t *testing.T) {
// The whole point. A copy of this database is not a copy of the mesh's credentials — which is
// what an encrypted column does not achieve, because whoever runs the control plane can read
// through it.
inv, ctx := twoNodesWithKeys(t)
got, err := inv.SecretFor(ctx, "database", "consumer", "provider")
if err != nil {
t.Fatal(err)
}
var columns []string
rows, err := inv.store.Pool().Query(ctx,
`select column_name from information_schema.columns where table_name = 'secret'`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
t.Fatal(err)
}
columns = append(columns, c)
}
for _, c := range columns {
if strings.Contains(c, "password") || strings.Contains(c, "value") ||
strings.Contains(c, "plain") {
t.Fatalf("the table has a column called %q, which suggests it holds the thing", c)
}
}
if got.ForConsumer == got.ForProvider {
t.Fatal("both ends were given the identical blob, so the storage reveals they match")
}
}
func TestANewSealingKeyMeansANewSecret(t *testing.T) {
// A node that rejoined generated a new key and can no longer open what was sealed to the old
// one. Keeping the blob would deliver something unreadable for ever, reported as configured.
inv, ctx := twoNodesWithKeys(t)
before, err := inv.SecretFor(ctx, "database", "consumer", "provider")
if err != nil {
t.Fatal(err)
}
node, err := inv.NodeByName(ctx, "consumer")
if err != nil {
t.Fatal(err)
}
fresh, _ := aSealingKey(t)
if err := inv.RecordSealingKey(ctx, node.ID, fresh); err != nil {
t.Fatal(err)
}
after, err := inv.SecretFor(ctx, "database", "consumer", "provider")
if err != nil {
t.Fatal(err)
}
if after.ForConsumer == before.ForConsumer {
t.Fatal("the node was handed a credential sealed to a key it no longer has")
}
// And the provider's copy changed too, in the same breath. Otherwise the two ends hold
// different passwords — which is the fanout window that makes rotation dangerous elsewhere.
if after.ForProvider == before.ForProvider {
t.Fatal("only one end was rotated, so the two now disagree")
}
}
func TestRotatingReachesBothEnds(t *testing.T) {
inv, ctx := twoNodesWithKeys(t)
before, err := inv.SecretFor(ctx, "database", "consumer", "provider")
if err != nil {
t.Fatal(err)
}
if err := inv.RotateSecret(ctx, "database", "consumer", "provider"); err != nil {
t.Fatal(err)
}
after, err := inv.SecretFor(ctx, "database", "consumer", "provider")
if err != nil {
t.Fatal(err)
}
if after.ForConsumer == before.ForConsumer || after.ForProvider == before.ForProvider {
t.Fatal("rotation left one of the ends holding what it had")
}
}
func TestAProviderIsToldEveryCredentialItMustCreate(t *testing.T) {
// The half that makes a credential real. A password nothing was told to create authenticates
// nowhere, and the mesh cannot tell the provider what it is in any other way — it cannot read
// it either.
inv, ctx := twoNodesWithKeys(t)
// The provider's own key, kept, so this asserts it can *open* what it was handed rather than
// that the field is non-empty. Without that, selecting the wrong column reads the same both
// ways and the test proves nothing — which it did, until the check was removed and it passed.
providerKey, openProvider := aSealingKey(t)
provider, err := inv.NodeByName(ctx, "provider")
if err != nil {
t.Fatal(err)
}
if err := inv.RecordSealingKey(ctx, provider.ID, providerKey); err != nil {
t.Fatal(err)
}
other, err := inv.AddNode(ctx, "second-consumer")
if err != nil {
t.Fatal(err)
}
secondKey, _ := aSealingKey(t)
if err := inv.RecordSealingKey(ctx, other.ID, secondKey); err != nil {
t.Fatal(err)
}
for _, who := range []string{"consumer", "second-consumer"} {
if _, err := inv.SecretFor(ctx, "database", who, "provider"); err != nil {
t.Fatal(err)
}
}
issued, err := inv.SecretsFrom(ctx, "provider")
if err != nil {
t.Fatal(err)
}
if len(issued) != 2 {
t.Fatalf("the provider was told about %d of 2", len(issued))
}
for _, s := range issued {
if _, ok := openProvider(s.ForProvider); !ok {
t.Fatalf("the provider cannot open the credential it was given for %s", s.Consumer)
}
if s.ForConsumer != "" {
// It has no business holding the other end's copy, and handing it out would put a
// second readable-by-someone-else copy into circulation.
t.Fatalf("the provider was handed the consumer's own copy of %s", s.Name)
}
}
}
func TestANodeWithNoSealingKeyCannotBeGivenASecret(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
for _, n := range []string{"consumer", "provider"} {
if _, err := inv.AddNode(ctx, n); err != nil {
t.Fatal(err)
}
}
_, err := inv.SecretFor(ctx, "database", "consumer", "provider")
if err == nil {
t.Fatal("a credential was made for nodes that cannot open one")
}
if !strings.Contains(err.Error(), "sealing key") {
t.Fatalf("the refusal does not say what is missing: %v", err)
}
}
func TestSecretsGoWhenANodeLeaves(t *testing.T) {
inv, ctx := twoNodesWithKeys(t)
if _, err := inv.SecretFor(ctx, "database", "consumer", "provider"); err != nil {
t.Fatal(err)
}
if _, err := inv.store.Pool().Exec(ctx, `delete from node where name = 'consumer'`); err != nil {
t.Fatal(err)
}
var left int
if err := inv.store.Pool().QueryRow(ctx, `select count(*) from secret`).Scan(&left); err != nil {
t.Fatal(err)
}
if left != 0 {
t.Fatalf("%d credential(s) outlived the machine they were for", left)
}
}
func TestAModulesOwnSecretIsPerMachineAndKept(t *testing.T) {
// A module running on three machines has three passwords. One in the manifest instead would
// put the same secret on every machine that ever runs it, in a file anybody can read.
inv, ctx := twoNodesWithKeys(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "1"},
Source{}); err != nil {
t.Fatal(err)
}
here, err := inv.SecretForModule(ctx, "consumer", "postgres", "superuser")
if err != nil {
t.Fatal(err)
}
there, err := inv.SecretForModule(ctx, "provider", "postgres", "superuser")
if err != nil {
t.Fatal(err)
}
if here == there {
t.Fatal("two machines were given the same secret")
}
// Made once and kept, or a running database would be handed a password it was not started
// with on the next declaration.
again, err := inv.SecretForModule(ctx, "consumer", "postgres", "superuser")
if err != nil {
t.Fatal(err)
}
if again != here {
t.Fatal("asking twice made a second secret")
}
}
func TestTwoNeedsInOneModuleAreTwoSecrets(t *testing.T) {
inv, ctx := twoNodesWithKeys(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "1"},
Source{}); err != nil {
t.Fatal(err)
}
one, err := inv.SecretForModule(ctx, "consumer", "postgres", "superuser")
if err != nil {
t.Fatal(err)
}
two, err := inv.SecretForModule(ctx, "consumer", "postgres", "replication")
if err != nil {
t.Fatal(err)
}
if one == two {
t.Fatal("two names gave one secret")
}
}
func TestAModulesSecretIsRemadeWhenTheMachineRejoins(t *testing.T) {
// The node generated a new sealing key and can no longer open what was sealed to the old one.
inv, ctx := twoNodesWithKeys(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "1"},
Source{}); err != nil {
t.Fatal(err)
}
before, err := inv.SecretForModule(ctx, "consumer", "postgres", "superuser")
if err != nil {
t.Fatal(err)
}
node, err := inv.NodeByName(ctx, "consumer")
if err != nil {
t.Fatal(err)
}
fresh, _ := aSealingKey(t)
if err := inv.RecordSealingKey(ctx, node.ID, fresh); err != nil {
t.Fatal(err)
}
after, err := inv.SecretForModule(ctx, "consumer", "postgres", "superuser")
if err != nil {
t.Fatal(err)
}
if after == before {
t.Fatal("a machine was handed a secret sealed to a key it no longer has")
}
}
func TestAMachineWithNoSealingKeyCannotBeGivenAModuleSecret(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
if _, err := inv.AddNode(ctx, "bare"); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "1"},
Source{}); err != nil {
t.Fatal(err)
}
if _, err := inv.SecretForModule(ctx, "bare", "postgres", "superuser"); err == nil {
t.Fatal("a secret was made for a machine that cannot open one")
}
}