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.
157 lines
4.9 KiB
Go
157 lines
4.9 KiB
Go
package secrets
|
|
|
|
import (
|
|
"crypto/ecdh"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/nacl/box"
|
|
)
|
|
|
|
// A node's key, as the node would generate it and report the public half.
|
|
func nodeKey(t *testing.T) (public string, open func(string) ([]byte, error)) {
|
|
t.Helper()
|
|
private, err := ecdh.X25519().GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var pub, priv [32]byte
|
|
copy(pub[:], private.PublicKey().Bytes())
|
|
copy(priv[:], private.Bytes())
|
|
return base64.StdEncoding.EncodeToString(private.PublicKey().Bytes()),
|
|
func(sealed string) ([]byte, error) {
|
|
blob, err := base64.StdEncoding.DecodeString(sealed)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out, ok := box.OpenAnonymous(nil, blob, &pub, &priv)
|
|
if !ok {
|
|
return nil, errNotForYou
|
|
}
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
var errNotForYou = ¬ForYou{}
|
|
|
|
type notForYou struct{}
|
|
|
|
func (*notForYou) Error() string { return "not sealed to this node" }
|
|
|
|
func TestBothEndsGetTheSameSecretAndTheMeshGetsNeither(t *testing.T) {
|
|
// The whole arrangement in one test. The provider must create the credential the consumer was
|
|
// given, or the mesh reports success and nothing can connect — and neither blob is readable
|
|
// by whoever is holding them, which is the part encrypting a column does not achieve.
|
|
consumerPub, openConsumer := nodeKey(t)
|
|
providerPub, openProvider := nodeKey(t)
|
|
|
|
sealed, err := Make(consumerPub, providerPub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
forConsumer, err := openConsumer(sealed.ForConsumer)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
forProvider, err := openProvider(sealed.ForProvider)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(forConsumer) != string(forProvider) {
|
|
t.Fatalf("the two ends were given different passwords: %q and %q",
|
|
forConsumer, forProvider)
|
|
}
|
|
if len(forConsumer) < 32 {
|
|
t.Fatalf("the password is %d characters, which is not a password", len(forConsumer))
|
|
}
|
|
// Neither can open the other's, which is what makes two blobs different from one shared key.
|
|
if _, err := openConsumer(sealed.ForProvider); err == nil {
|
|
t.Fatal("the consumer opened the provider's copy")
|
|
}
|
|
}
|
|
|
|
func TestTheSealedFormLooksNothingLikeTheSecret(t *testing.T) {
|
|
consumerPub, openConsumer := nodeKey(t)
|
|
providerPub, _ := nodeKey(t)
|
|
sealed, err := Make(consumerPub, providerPub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
password, err := openConsumer(sealed.ForConsumer)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(sealed.ForConsumer, string(password)) {
|
|
t.Fatal("the secret is visible inside what is stored")
|
|
}
|
|
if sealed.ForConsumer == sealed.ForProvider {
|
|
// Sealed boxes are randomised, so an observer cannot tell the two ends hold the same
|
|
// value — nor that a rotation changed nothing.
|
|
t.Fatal("the two blobs are identical, so the storage says they hold the same value")
|
|
}
|
|
}
|
|
|
|
func TestAPasswordIsSafeToPutInAFile(t *testing.T) {
|
|
// It lands in a file something else reads. A newline or a quote in it is a support call.
|
|
consumerPub, openConsumer := nodeKey(t)
|
|
providerPub, _ := nodeKey(t)
|
|
for i := 0; i < 50; i++ {
|
|
sealed, err := Make(consumerPub, providerPub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
password, err := openConsumer(sealed.ForConsumer)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.ContainsAny(string(password), "\n\r\t \"'\\$`") {
|
|
t.Fatalf("a password needs quoting: %q", password)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEverySecretIsDifferent(t *testing.T) {
|
|
consumerPub, openConsumer := nodeKey(t)
|
|
providerPub, _ := nodeKey(t)
|
|
seen := map[string]bool{}
|
|
for i := 0; i < 50; i++ {
|
|
sealed, _ := Make(consumerPub, providerPub)
|
|
password, _ := openConsumer(sealed.ForConsumer)
|
|
if seen[string(password)] {
|
|
t.Fatalf("the same password came out twice: %q", password)
|
|
}
|
|
seen[string(password)] = true
|
|
}
|
|
}
|
|
|
|
func TestAnEndWithNoSealingKeyIsRefused(t *testing.T) {
|
|
// Sealing to nothing would produce a blob nobody can open, stored as though it were a working
|
|
// credential — which is the failure this whole design exists to make impossible.
|
|
//
|
|
// Asserted on the message, not merely on failing. Seal refuses an empty key anyway, so a test
|
|
// that only checked for an error passed with this check removed and proved nothing about it.
|
|
// What the check adds is a reason a person can act on: the remedy is on the node, not here.
|
|
public, _ := nodeKey(t)
|
|
for _, pair := range [][2]string{{public, ""}, {"", public}} {
|
|
_, err := Make(pair[0], pair[1])
|
|
if err == nil {
|
|
t.Fatal("a secret was made for a node with no sealing key")
|
|
}
|
|
if !strings.Contains(err.Error(), "both ends need a sealing key") {
|
|
t.Fatalf("the refusal does not say what is missing: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSomethingThatIsNotAKeyIsRefused(t *testing.T) {
|
|
if _, err := Seal("not-a-key", []byte("x")); err == nil {
|
|
t.Fatal("a secret was sealed to nonsense")
|
|
}
|
|
short := base64.StdEncoding.EncodeToString([]byte("too short"))
|
|
if _, err := Seal(short, []byte("x")); err == nil {
|
|
t.Fatal("a secret was sealed to a key of the wrong length")
|
|
}
|
|
}
|