Files
mesh-controller/internal/broker/broker.go
T
jschoubben ea6569277d 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.
2026-08-29 15:13:54 +02:00

91 lines
3.4 KiB
Go

// 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
}