A build machine is told what to check the broker against
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.
This commit is contained in:
+61
-10
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user