Credentials the mesh delivers and cannot read

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.
This commit is contained in:
2026-08-30 00:21:18 +02:00
parent c4782ae2fd
commit 20f78cd5f1
13 changed files with 883 additions and 14 deletions
+91
View File
@@ -0,0 +1,91 @@
package secrets
import (
"crypto/rand"
"encoding/base64"
"fmt"
"golang.org/x/crypto/nacl/box"
)
// Secrets the mesh delivers and cannot read.
//
// **What this is not.** The obvious arrangement is a credentials column, encrypted at rest. It
// has been built, in another mesh, and that mesh's own tooling records what it bought: a query
// against the encrypted column returns zero rows and proves nothing, so auditing moved to the
// decrypted copies on the nodes; and the tool for finding a secret has to search **by value**
// rather than by name, because the same password sits in the provisions table, in the environment
// table, in each node's environment file in plain text, and inside every connection string
// composed from it — copies its own documentation calls "often the only copies actually in use".
//
// Two faults there, and encryption at rest addresses neither. **The control plane can read what
// it stores**, so a copy of its database is a copy of every credential in the mesh. And **one
// secret has many homes with nothing tracking them.**
//
// So here the value is sealed to the node that will use it before it is stored, with a key that
// node generated and whose private half the mesh has never seen. What gets written is unusable by
// whoever holds it, the mesh included. And nothing is composed centrally — a connection string is
// assembled on the machine that needs one, so the mesh never mints a second copy in a shape
// nothing tracks.
// Sealed is one value, closed to both ends of a provision.
//
// Two blobs of the same secret rather than one shared key: a key both ends hold is a key the mesh
// would have to distribute, which is this problem again one level down.
type Sealed struct {
ForConsumer string
ForProvider string
// Which key each was sealed to, kept so a node that regenerated its key can be told what it
// can no longer open rather than discovering it as a service that will not start.
ConsumerKey string
ProviderKey string
}
// Make generates a secret and seals it to both ends, keeping no readable copy.
//
// The plaintext exists for the length of this call. Rotation is therefore generating a new one
// rather than reading the old one back — the only version of rotation that is honest about what
// the mesh knows.
func Make(consumerKey, providerKey string) (Sealed, error) {
if consumerKey == "" || providerKey == "" {
// Sealing to an empty key would produce a blob nobody can open, stored as though it were
// a working credential. The caller knows which node is which and says so.
return Sealed{}, fmt.Errorf("both ends need a sealing key before a secret can be made")
}
value := make([]byte, 32)
if _, err := rand.Read(value); err != nil {
return Sealed{}, err
}
// Base64 without padding, because it lands in a configuration file something else parses and
// a password containing a newline or a quote is a support call.
password := base64.RawURLEncoding.EncodeToString(value)
forConsumer, err := Seal(consumerKey, []byte(password))
if err != nil {
return Sealed{}, err
}
forProvider, err := Seal(providerKey, []byte(password))
if err != nil {
return Sealed{}, err
}
return Sealed{
ForConsumer: forConsumer, ForProvider: forProvider,
ConsumerKey: consumerKey, ProviderKey: providerKey,
}, nil
}
// Seal closes a value to a node's public sealing key.
func Seal(publicKey string, value []byte) (string, error) {
public, err := base64.StdEncoding.DecodeString(publicKey)
if err != nil || len(public) != 32 {
return "", fmt.Errorf("%q is not a sealing key", publicKey)
}
var pub [32]byte
copy(pub[:], public)
sealed, err := box.SealAnonymous(nil, value, &pub, rand.Reader)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(sealed), nil
}