From ea6569277d84f2109be5cc0543a4f996af8f4eba Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 29 Aug 2026 15:13:54 +0200 Subject: [PATCH] A token with all four parts Given the broker's address and its certificate, mesh-control now issues a token carrying everything ADR 0004 asks for: where to connect, what to expect there, whose signature to believe afterwards, and a one-time right to join. Verified by decoding one and checking the fingerprint against `openssl x509 | sha256sum` -- they match. The fingerprint is derived from the certificate on disk and never configured. A configured pin can drift from the certificate it describes, and a drifted pin is worse than none: every node issued a token during the drift refuses to connect, and the failure looks like an attack rather than a mistake. Computed over DER, which is what a client sees on the wire. Hashing the PEM text instead would mean the same certificate, re-wrapped with different line endings, produced a different pin -- there is a test for exactly that, and one for pointing this at tls.key by mistake, which would otherwise produce a confident pin over the wrong file. Having no broker stays a state rather than a failure: a control plane holds records and a signing key without one. Having half a broker is refused, because a token with an address and nothing to check it against invites a node to trust whatever answers. Fault injection caught the same weak test I wrote earlier in the day -- asking whether something failed rather than why, so deleting the guard changed nothing because it failed one line later anyway. Both are now asserted on the reason. --- README.md | 14 ++- cmd/mesh-control/main.go | 41 +++++++- internal/broker/broker.go | 90 +++++++++++++++++ internal/broker/broker_test.go | 180 +++++++++++++++++++++++++++++++++ 4 files changed, 320 insertions(+), 5 deletions(-) create mode 100644 internal/broker/broker.go create mode 100644 internal/broker/broker_test.go diff --git a/README.md b/README.md index 8909867..b1498ee 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ mesh-control node list the nodes this mesh knows about mesh-control token issue --node a one-time right to join, for an existing record mesh-control token issue --new create the record and issue for it mesh-control identity show this control plane's signing key +mesh-control broker show where the broker is, and what to expect there mesh-control version what this binary is ``` @@ -65,9 +66,16 @@ travels in every token. A node believes a declaration because it carries a signa transitive, so a compromised broker could forge declarations, and since the host applies whatever the link delivers that is the whole machine. -**Still missing: the broker's address and its certificate fingerprint.** Both are step 5 of the -substrate bootstrap and neither exists. `token issue` prints the token **and names what is -missing**, rather than producing something that looks complete and cannot be used. +**All four parts are built.** Given `MESH_BROKER_ADDRESS` and `MESH_BROKER_CERTIFICATE`, a token +carries everything ADR 0004 requires. Without them it carries two, and `token issue` prints it +**and names what is missing** rather than producing something that looks complete and cannot be +used. + +**The fingerprint is derived from the certificate, never configured.** A configured one can drift +from the certificate it describes, and a drifted pin is worse than none: every node issued a token +during the drift refuses to connect, and the failure looks like an attack. It is computed over the +DER bytes — what a client actually sees on the wire — so the same certificate re-wrapped with +different line endings still produces the same pin. ### Two contexts, and the rule between them is real diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 393e422..63f1d8b 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -16,6 +16,7 @@ import ( "syscall" "time" + "github.com/novox/mesh-control/internal/broker" "github.com/novox/mesh-control/internal/identity" "github.com/novox/mesh-control/internal/inventory" "github.com/novox/mesh-control/internal/store" @@ -64,6 +65,8 @@ func run() error { return tokenCommand(ctx, args[1:]) case "identity": return identityCommand(ctx, args[1:]) + case "broker": + return brokerCommand(args[1:]) case "version": fmt.Println(version) return nil @@ -85,6 +88,7 @@ func usage() { token issue --node a one-time right to join, for an existing record token issue --new create the record and issue for it identity show this control plane's signing key + broker show where the broker is, and what to expect there version what this binary is Each context reaches its own store through its own credential (novox/hq ADR 0008), named @@ -250,6 +254,17 @@ func tokenCommand(ctx context.Context, args []string) error { } made := token.Token{Signer: key.Public, Secret: issued.Secret} + + // Absent is a state, not a failure: a control plane can hold records and a key before it has + // a broker. What it cannot do is issue a token anybody could use, and Missing() says so. + known, err := broker.FromEnvironment() + switch { + case err == nil: + made.Broker, made.Fingerprint = known.Address, known.Fingerprint + case errors.Is(err, broker.ErrNotConfigured): + default: + return err + } encoded, err := made.Encode() if err != nil { return err @@ -264,8 +279,8 @@ func tokenCommand(ctx context.Context, args []string) error { for _, m := range missing { fmt.Printf(" - %s\n", m) } - fmt.Println("\nThe broker and its certificate are step 5 of the substrate bootstrap and " + - "do not exist yet\n(novox/hq 07-the-substrate). The signing key above is real.") + fmt.Printf("\nSet %s and %s once the broker is raised.\n", + broker.AddressVar, broker.CertificateVar) } return nil } @@ -305,3 +320,25 @@ func identityCommand(ctx context.Context, args []string) error { "declaration because it carries a signature this key made (novox/hq ADR 0004).\n") return nil } + +func brokerCommand(args []string) error { + if len(args) == 0 || args[0] != "show" { + return errors.New("broker show") + } + known, err := broker.FromEnvironment() + if errors.Is(err, broker.ErrNotConfigured) { + fmt.Printf("no broker configured. Set %s and %s.\n\n"+ + "Until then tokens carry the signing key and the one-time secret, and say what they\n"+ + "are missing. They cannot be used to join.\n", + broker.AddressVar, broker.CertificateVar) + return nil + } + if err != nil { + return err + } + fmt.Printf("address %s\n", known.Address) + fmt.Printf("fingerprint %s\n", known.Fingerprint) + fmt.Print("\nThe fingerprint is computed from the certificate on disk, never configured. A\n" + + "node checks it before sending anything (novox/hq ADR 0004).\n") + return nil +} diff --git a/internal/broker/broker.go b/internal/broker/broker.go new file mode 100644 index 0000000..84025ef --- /dev/null +++ b/internal/broker/broker.go @@ -0,0 +1,90 @@ +// Package broker is what the control plane knows about the broker nodes dial. +// +// Two facts, and a token needs both (novox/hq ADR 0004): where it is, and what certificate to +// expect there. They are the two parts of a token this control plane does not generate itself. +// +// The address is configuration. The fingerprint is **not** — it is derived from the certificate +// the broker is actually serving. Configuring a fingerprint separately would let it drift from +// the certificate it describes, and a drifted pin is worse than none: every node issued a token +// during the drift refuses to connect, and the failure looks like an attack. +package broker + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "os" + "strings" +) + +// Where the two settings come from. +const ( + AddressVar = "MESH_BROKER_ADDRESS" + CertificateVar = "MESH_BROKER_CERTIFICATE" +) + +// Broker is what a token needs to say about it. +type Broker struct { + Address string + Fingerprint string +} + +// ErrNotConfigured means this control plane has not been told where its broker is. +// +// Not a failure to start. A control plane can hold node records and a signing key without one; +// what it cannot do is issue a token anybody could use, and that is where this surfaces. +var ErrNotConfigured = errors.New("this control plane has not been told about its broker") + +// FromEnvironment reads the two settings, if they are there. +func FromEnvironment() (Broker, error) { + address := strings.TrimSpace(os.Getenv(AddressVar)) + path := strings.TrimSpace(os.Getenv(CertificateVar)) + + if address == "" && path == "" { + return Broker{}, ErrNotConfigured + } + // One without the other is worse than neither: a token with an address and no fingerprint + // invites a node to connect to something it cannot check. + if address == "" || path == "" { + return Broker{}, fmt.Errorf( + "%s and %s must be set together — an address with nothing to check the certificate "+ + "against is a node connecting to whatever answers", AddressVar, CertificateVar) + } + + fingerprint, err := FingerprintOf(path) + if err != nil { + return Broker{}, err + } + return Broker{Address: address, Fingerprint: fingerprint}, nil +} + +// FingerprintOf reads a PEM certificate and returns what a client pins. +// +// SHA-256 over the DER bytes, which is what a TLS client can compute from the certificate the +// server presents — so the two are comparing the same thing. A digest over the PEM text would +// not be: the same certificate re-wrapped with different line endings would hash differently +// while being the same certificate. +func FingerprintOf(path string) (string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("cannot read the broker's certificate at %s: %w", path, err) + } + + block, _ := pem.Decode(raw) + if block == nil || block.Type != "CERTIFICATE" { + return "", fmt.Errorf( + "%s does not contain a PEM certificate. If this is a private key, it is the wrong "+ + "file — what a node pins is the certificate the broker presents", path) + } + // Parsed rather than hashed straight from the block, so a malformed certificate is caught + // here rather than becoming a pin that matches nothing. + if _, err := x509.ParseCertificate(block.Bytes); err != nil { + return "", fmt.Errorf("the certificate at %s could not be parsed: %w", path, err) + } + + sum := sha256.Sum256(block.Bytes) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} diff --git a/internal/broker/broker_test.go b/internal/broker/broker_test.go new file mode 100644 index 0000000..94e8810 --- /dev/null +++ b/internal/broker/broker_test.go @@ -0,0 +1,180 @@ +package broker + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "errors" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// writeCertificate puts a real self-signed certificate on disk and returns its path and the +// DER bytes, which is what a TLS client would see on the wire. +func writeCertificate(t *testing.T) (string, []byte) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "mesh-broker"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "tls.crt") + if err := os.WriteFile(path, pem.EncodeToMemory( + &pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil { + t.Fatal(err) + } + return path, der +} + +func TestTheFingerprintIsOverWhatAClientSees(t *testing.T) { + // A node computes this from the certificate the broker presents, which is DER on the wire. + // Hashing the PEM text instead would mean the same certificate, re-wrapped with different + // line endings, produced a different pin — and every token issued around that moment would + // send a node to something it refuses to talk to. + path, der := writeCertificate(t) + + got, err := FingerprintOf(path) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(der) + want := "sha256:" + hex.EncodeToString(sum[:]) + if got != want { + t.Errorf("fingerprint is %s, and a client computing it from the wire gets %s", got, want) + } +} + +func TestReWrappingTheSameCertificateDoesNotChangeThePin(t *testing.T) { + // The property the test above protects, stated directly: same certificate, different file + // formatting, same pin. + path, der := writeCertificate(t) + first, err := FingerprintOf(path) + if err != nil { + t.Fatal(err) + } + + rewrapped := filepath.Join(t.TempDir(), "same.crt") + body := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + if err := os.WriteFile(rewrapped, append([]byte("\n\n"), body...), 0o644); err != nil { + t.Fatal(err) + } + second, err := FingerprintOf(rewrapped) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Errorf("the same certificate produced two pins:\n %s\n %s", first, second) + } +} + +func TestAPrivateKeyIsNotACertificate(t *testing.T) { + // The mistake somebody makes once: pointing this at tls.key. Left unchecked it would produce + // a confident pin over the wrong file, and every node would refuse the broker. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "tls.key") + if err := os.WriteFile(path, pem.EncodeToMemory( + &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), 0o600); err != nil { + t.Fatal(err) + } + + _, err = FingerprintOf(path) + if err == nil { + t.Fatal("a private key was fingerprinted as if it were a certificate") + } + if !strings.Contains(err.Error(), "private key") { + t.Errorf("the error does not say what the file actually is: %v", err) + } +} + +func TestAMalformedCertificateIsRefusedRatherThanHashed(t *testing.T) { + // Bytes wrapped in the right PEM header are not a certificate. Hashing them would produce a + // pin that matches nothing, and the failure would arrive on a node instead of here. + path := filepath.Join(t.TempDir(), "broken.crt") + if err := os.WriteFile(path, pem.EncodeToMemory( + &pem.Block{Type: "CERTIFICATE", Bytes: []byte("not a certificate")}), 0o644); err != nil { + t.Fatal(err) + } + if _, err := FingerprintOf(path); err == nil { + t.Fatal("malformed bytes were accepted as a certificate") + } +} + +func TestNoBrokerIsAStateAndNotAFailure(t *testing.T) { + t.Setenv(AddressVar, "") + t.Setenv(CertificateVar, "") + if _, err := FromEnvironment(); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("expected ErrNotConfigured, got %v", err) + } +} + +func TestAnAddressWithoutACertificateIsRefused(t *testing.T) { + // Worse than neither: a token with somewhere to connect and nothing to check would have a + // node trust whatever answers at that address. + // + // Asserted on which refusal fired. Without the check this still fails a line later, trying to + // read a certificate at the empty path — so a test asking only "was there an error" passes + // with the guard deleted. It was written that way first and confirmed to defend nothing. + t.Setenv(AddressVar, "192.0.2.10:5671") + t.Setenv(CertificateVar, "") + + _, err := FromEnvironment() + if err == nil || errors.Is(err, ErrNotConfigured) { + t.Fatalf("an address with no certificate was accepted: %v", err) + } + if !strings.Contains(err.Error(), "must be set together") { + t.Errorf("refused for the wrong reason: %v", err) + } +} + +func TestACertificateWithoutAnAddressIsRefused(t *testing.T) { + path, _ := writeCertificate(t) + t.Setenv(AddressVar, "") + t.Setenv(CertificateVar, path) + + _, err := FromEnvironment() + if err == nil || errors.Is(err, ErrNotConfigured) { + t.Fatalf("a certificate with no address was accepted: %v", err) + } + if !strings.Contains(err.Error(), "must be set together") { + t.Errorf("refused for the wrong reason: %v", err) + } +} + +func TestBothTogetherGiveABroker(t *testing.T) { + path, _ := writeCertificate(t) + t.Setenv(AddressVar, "192.0.2.10:5671") + t.Setenv(CertificateVar, path) + + known, err := FromEnvironment() + if err != nil { + t.Fatal(err) + } + if known.Address != "192.0.2.10:5671" || !strings.HasPrefix(known.Fingerprint, "sha256:") { + t.Errorf("got %+v", known) + } +}