From 78c5b653cf6d3e43d1d7303723393c886f5563eb Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 30 Aug 2026 03:47:04 +0200 Subject: [PATCH] A secret the mesh is given, not one it made MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last of the four gaps ADR 0024 names. Everything the mesh handles today it generated itself, sealed to both ends, and discarded. An API key for a hosted service comes from a person, and carrying it needs a verb the mesh did not have. Accept seals it on the way in and keeps no plaintext — the same storage and the same property as a generated one, only a different origin. That is the whole difference from the arrangement being replaced, where an operator-supplied key sits in a column the control plane can read, which makes a copy of the database a copy of every account the mesh touches. The consequence is deliberate: the mesh cannot show it back. Somebody who loses the key gets a new one from wherever it came from. There is no reveal and there cannot be one, because a mesh that can reveal a secret is a mesh that holds it — asserted as a test, because it is a property somebody will eventually ask to break. An empty value is refused. A credential that exists, authenticates nowhere and looks exactly like a working one is the failure this whole mechanism is arranged to prevent. --- internal/secrets/seal.go | 37 +++++++++++++++++++++ internal/secrets/seal_test.go | 60 +++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/internal/secrets/seal.go b/internal/secrets/seal.go index cff6eb0..422c94f 100644 --- a/internal/secrets/seal.go +++ b/internal/secrets/seal.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "encoding/base64" "fmt" + "strings" "golang.org/x/crypto/nacl/box" ) @@ -75,6 +76,42 @@ func Make(consumerKey, providerKey string) (Sealed, error) { }, nil } +// Accept seals a value somebody supplied, rather than one the mesh made. +// +// **The mesh generates most of what it hands out and discards the plaintext.** An API key for a +// hosted service does not work that way: it comes from a person, and the mesh's job is to carry it +// to the machines that need it without being able to read it afterwards +// (novox/hq ADR 0024). +// +// So the value is sealed on the way in and **the plaintext is not kept**. That is the whole of the +// difference from the arrangement this replaces, where an operator-supplied key sits in a column +// the control plane can read — which makes a copy of the database a copy of every account the mesh +// touches. +// +// The consequence is deliberate and worth stating: **the mesh cannot show it back.** Somebody who +// loses the key gets a new one from wherever it came from; there is no "reveal" and there cannot +// be one, because a mesh that can reveal a secret is a mesh that holds it. +func Accept(value string, consumerKey, providerKey string) (Sealed, error) { + if strings.TrimSpace(value) == "" { + return Sealed{}, fmt.Errorf("there is nothing to seal") + } + if consumerKey == "" || providerKey == "" { + return Sealed{}, fmt.Errorf("both ends need a sealing key before a secret can be kept") + } + forConsumer, err := Seal(consumerKey, []byte(value)) + if err != nil { + return Sealed{}, err + } + forProvider, err := Seal(providerKey, []byte(value)) + 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) diff --git a/internal/secrets/seal_test.go b/internal/secrets/seal_test.go index 17cfde9..8eb347f 100644 --- a/internal/secrets/seal_test.go +++ b/internal/secrets/seal_test.go @@ -4,6 +4,7 @@ import ( "crypto/ecdh" "crypto/rand" "encoding/base64" + "encoding/json" "strings" "testing" @@ -154,3 +155,62 @@ func TestSomethingThatIsNotAKeyIsRefused(t *testing.T) { t.Fatal("a secret was sealed to a key of the wrong length") } } + +func TestASecretSomebodySuppliedIsKeptTheSameWay(t *testing.T) { + // An API key comes from a person; the mesh's job is to carry it without being able to read it + // afterwards. Same storage, same property, different origin. + consumerPub, openConsumer := nodeKey(t) + providerPub, openProvider := nodeKey(t) + + sealed, err := Accept("sk-a-real-looking-key", consumerPub, providerPub) + if err != nil { + t.Fatal(err) + } + got, err := openConsumer(sealed.ForConsumer) + if err != nil { + t.Fatal(err) + } + if string(got) != "sk-a-real-looking-key" { + t.Fatalf("the value did not survive: %q", got) + } + if _, err := openProvider(sealed.ForProvider); err != nil { + t.Fatal("the other end cannot open its copy") + } + // And what is stored is not the value, which is the whole point. + for what, blob := range map[string]string{ + "the consumer's copy": sealed.ForConsumer, "the provider's copy": sealed.ForProvider, + } { + if strings.Contains(blob, "sk-a-real-looking-key") { + t.Fatalf("%s holds the key in the clear", what) + } + } +} + +func TestSealingNothingIsRefused(t *testing.T) { + // An empty key sealed and stored would be a credential that exists, authenticates nowhere, + // and looks exactly like a working one. + public, _ := nodeKey(t) + for _, value := range []string{"", " ", "\n"} { + if _, err := Accept(value, public, public); err == nil { + t.Fatalf("%q was accepted as a secret", value) + } + } +} + +func TestAnAcceptedSecretCannotBeReadBack(t *testing.T) { + // Stated as a test because it is a property somebody will ask to break. There is no field + // holding the value and no function returning it — a mesh that can reveal a secret is a mesh + // that holds it. + public, _ := nodeKey(t) + sealed, err := Accept("sk-something", public, public) + if err != nil { + t.Fatal(err) + } + said, err := json.Marshal(sealed) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(said), "sk-something") { + t.Fatalf("what is kept carries the secret: %s", said) + } +}