From a5b11fd6b2a3ab24af9c628d632dbd55024395ff Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 31 Aug 2026 00:55:33 +0200 Subject: [PATCH] A build machine is told what to check the broker against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credential was a URL and nothing else, so the builder verified the broker the ordinary way — against public roots. A mesh's broker presents a certificate of the mesh's own, which is in no trust store anywhere, so the connection could only ever succeed against a broker somebody else vouches for. It failed at TLS with an error about an unknown authority rather than about a missing pin, and the container sat there running: up, credential on disk, connected to nothing. So the sealed credential now carries the URL and the broker's fingerprint — the same two facts a node's token carries, for the same reason, delivered out of band relative to the thing being trusted. The builder pins it: the standard chain check is replaced rather than removed, and what replaces it is stricter, accepting one certificate instead of every certificate a public authority would sign. A file holding only a URL still works, for a builder somebody runs by hand against a broker with an ordinary certificate. --- cmd/mesh-builder/main.go | 71 +++++++++++++++++++++++++++++----- cmd/mesh-builder/where_test.go | 30 +++++++++++++- cmd/mesh-control/main.go | 21 +++++++++- 3 files changed, 108 insertions(+), 14 deletions(-) diff --git a/cmd/mesh-builder/main.go b/cmd/mesh-builder/main.go index 2790411..c19a508 100644 --- a/cmd/mesh-builder/main.go +++ b/cmd/mesh-builder/main.go @@ -17,7 +17,12 @@ package main import ( "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" "encoding/json" + "errors" "fmt" "os" "os/signal" @@ -65,7 +70,7 @@ func run() error { } } - amqpURL, err := brokerFrom() + credential, err := brokerFrom() if err != nil { return err } @@ -85,7 +90,7 @@ func run() error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - conn, err := amqp.Dial(amqpURL) + conn, err := dial(credential) if err != nil { // Not quoted back: the URL carries this builder's broker password. return fmt.Errorf("cannot reach the broker: %w", err) @@ -270,25 +275,71 @@ func whereToPublish() (string, error) { // the one copy that matters passing through a terminal and a process listing. // // The variable remains for a builder run by a person. -func brokerFrom() (string, error) { +func brokerFrom() (Credential, error) { if path := strings.TrimSpace(os.Getenv("MESH_BROKER_FILE")); path != "" { raw, err := os.ReadFile(path) if err != nil { - return "", fmt.Errorf("cannot read this builder's credential: %w", err) + return Credential{}, fmt.Errorf("cannot read this builder's credential: %w", err) } - url := strings.TrimSpace(string(raw)) - if url == "" { + said := strings.TrimSpace(string(raw)) + if said == "" { // An empty credential file is a machine that will connect as nobody and be refused, // with the reason three layers away. - return "", fmt.Errorf("%s is empty, so this builder has no credential", path) + return Credential{}, fmt.Errorf("%s is empty, so this builder has no credential", path) } - return url, nil + var held Credential + if err := json.Unmarshal([]byte(said), &held); err == nil && held.URL != "" { + return held, nil + } + // A file holding only a URL, which is what a person writing one by hand produces. The + // broker is then verified against whatever this machine already trusts. + return Credential{URL: said}, nil } url := strings.TrimSpace(os.Getenv("MESH_BROKER_AMQP")) if url == "" { - return "", fmt.Errorf( + return Credential{}, fmt.Errorf( "neither MESH_BROKER_FILE nor MESH_BROKER_AMQP: a builder with no broker has " + "nothing to build") } - return url, nil + return Credential{URL: url}, nil +} + +// Credential is what a build machine is given so it can reach the broker. +// +// Two things, because reaching a broker over TLS needs both: who to connect as, and what to check +// the certificate against. A mesh's broker presents a certificate of the mesh's own, which is in +// no public trust store, so a URL alone can only connect to a broker somebody else vouches for. +// +// **The same shape a node gets, for the same reason** (novox/hq ADR 0004): the fingerprint travels +// out of band — here, sealed with the credential — and the endpoint is verified once at connect. +type Credential struct { + URL string `json:"url"` + // Fingerprint is SHA-256 over the broker certificate's DER bytes, or empty to verify the + // ordinary way. + Fingerprint string `json:"fingerprint,omitempty"` +} + +// dial opens the connection, pinning the broker's certificate when there is one to pin. +func dial(held Credential) (*amqp.Connection, error) { + if held.Fingerprint == "" { + return amqp.Dial(held.URL) + } + // InsecureSkipVerify with a VerifyPeerCertificate is **pinning, not skipping**: the standard + // chain check is replaced, not removed, and what replaces it is stricter — one certificate is + // accepted rather than every certificate a public authority would sign. + return amqp.DialTLS(held.URL, &tls.Config{ + InsecureSkipVerify: true, + VerifyPeerCertificate: func(raw [][]byte, _ [][]*x509.Certificate) error { + if len(raw) == 0 { + return errors.New("the broker presented no certificate") + } + got := sha256.Sum256(raw[0]) + if hex.EncodeToString(got[:]) != held.Fingerprint { + return fmt.Errorf( + "this is not the broker this builder was told about: it presented a "+ + "certificate with fingerprint %s", hex.EncodeToString(got[:])) + } + return nil + }, + }) } diff --git a/cmd/mesh-builder/where_test.go b/cmd/mesh-builder/where_test.go index c7baa20..800ca35 100644 --- a/cmd/mesh-builder/where_test.go +++ b/cmd/mesh-builder/where_test.go @@ -90,8 +90,34 @@ func TestTheCredentialComesFromAFileTheMeshSealed(t *testing.T) { if err != nil { t.Fatal(err) } - if got != "amqps://a-builder:secret@broker.internal:5671/" { - t.Fatalf("got %q", got) + if got.URL != "amqps://a-builder:secret@broker.internal:5671/" { + t.Fatalf("got %q", got.URL) + } +} + +// The credential the mesh seals carries what to check the broker's certificate against, because a +// mesh's broker presents a certificate of the mesh's own and no public trust store has it. A URL +// alone can only reach a broker somebody else vouches for. +func TestTheSealedCredentialCarriesWhatVerifiesTheBroker(t *testing.T) { + path := filepath.Join(t.TempDir(), "broker") + if err := os.WriteFile(path, []byte( + `{"url":"amqps://a-builder:secret@broker.internal:5671/","fingerprint":"abc123"}`), + 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("MESH_BROKER_FILE", path) + t.Setenv("MESH_BROKER_AMQP", "") + + got, err := brokerFrom() + if err != nil { + t.Fatal(err) + } + if got.URL != "amqps://a-builder:secret@broker.internal:5671/" { + t.Fatalf("the url was lost: %q", got.URL) + } + if got.Fingerprint != "abc123" { + t.Fatal("the builder was given nothing to check the broker against, so it can only " + + "connect to a broker some public authority vouches for") } } diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 5e863a4..d068d38 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -2196,8 +2196,25 @@ func builderCommand(ctx context.Context, args []string) error { } defer inv.Close() - url := fmt.Sprintf("amqps://%s:%s@%s/", name, password, known.Address) - if err := inv.AcceptSecretForModule(ctx, *forNode, *module, "broker", url); err != nil { + // The URL and what verifies the broker, together. A mesh's broker presents a certificate + // of the mesh's own, which is in no public trust store — so a URL on its own reaches only + // a broker somebody else vouches for, and the connection fails at TLS with an error about + // an unknown authority rather than about a missing pin. + // + // **The same two facts a node's token carries** (novox/hq ADR 0004), delivered the same + // way: out of band relative to the broker, so what is trusted does not come from the thing + // being trusted. + held, err := json.Marshal(struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + }{ + URL: fmt.Sprintf("amqps://%s:%s@%s/", name, password, known.Address), + Fingerprint: known.Fingerprint, + }) + if err != nil { + return err + } + if err := inv.AcceptSecretForModule(ctx, *forNode, *module, "broker", string(held)); err != nil { return err } // Not printed. It is sealed to that machine and the mesh cannot read it back, which is