HAL keeps env vars in the registry, encrypted at rest. Its own tooling records what that bought and what it did not. `secret_locate` matches by value rather than by name — because the same password sits in mesh_provisions, in module_env, in each node's .env in plain text, and inside every connection string composed from it, and its documentation says those URL copies "are often the only copies actually in use". And a query against the encrypted column returns zero rows and proves nothing, so auditing moved to the decrypted copies on the nodes. Two faults there, and encryption at rest addresses neither: the control plane can read what it stores, so a copy of the database is a copy of every credential; and one secret has many homes with nothing tracking them. So here the mesh generates a password, seals it to each end with keys those nodes generated, stores both blobs, and discards the plaintext. It cannot read what it holds. Neither can the broker relaying it. And nothing is composed centrally — a connection string is assembled on the machine that needs one — so no copy is ever minted in a shape nothing tracks. `Compromise of a node is compromise of that node` (ADR 0004) is now true of secrets, not only of identity. Two files rather than one, because the mesh cannot compose a document containing a value it discarded: `binds` carries the readable facts, `secrets` carries the credential alone. The readable half stays readable in the declaration; the secret half changes only when the secret does, which makes restart-on precise. The provider gets a directory, one file per consumer, for the same reason. It is made once and kept — regenerating per declaration would restart both ends on every push, and the password a provider was told to create would never be the one its consumer was given. It is remade when either end's sealing key changes, and both ends learn the new one in the same push, so there is no window where half the mesh holds a dead credential. Two tests found passing for the wrong reason, both caught because their injection came back clean: - the provider's copy was asserted non-empty, which reads the same whichever column is selected. It now opens the blob with the provider's own key. - RotateSecret deleted and re-created; the re-create was dead, because the next read makes one anyway. Removed, and a second path to the same act is how two ends come to disagree. And one real fault: three places built a declaration, and the one behind `--json` predated credentials, so it silently produced a declaration missing them — a difference between what `plan` showed and what anything reading `--json` got. There is one path now.
233 lines
7.3 KiB
Go
233 lines
7.3 KiB
Go
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdh"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"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)
|
|
}
|
|
}
|