Files
mesh-host/internal/link/pinned.go
jschoubben 65d896d96e A node makes its own identity, and checks the broker before speaking
The host side of enrolment. It parses a token the control plane issued, dials
the broker, refuses anything but the pinned certificate, and generates an
Ed25519 keypair whose private half never leaves the machine.

Verified against a real LavinMQ serving a real certificate: the pin matched and
the node proceeded. Then against a second broker with a different certificate
on another port, which was refused -- with an error that says retrying will not
help, because it does not mean the network is down, it means the mesh was
substituted.

InsecureSkipVerify is set and that is the point rather than a weakening. At
bootstrap the broker is self-signed and reached at an address, so there is no
authority to trace and no name to match. Chain and hostname checks are replaced
with something stricter: this exact certificate or nothing, checked in
VerifyPeerCertificate, which runs before the handshake completes -- so nothing
is sent to the wrong broker. There is a test that counts the bytes an impostor
receives, and it is zero.

The token format is defined separately here and in the control plane, because
this binary requires nothing present and does not import it. They are held
together by a test on each side asserting the exact field names, so a rename
breaks both immediately rather than at enrolment on a real machine.

Two distinctions the identity file has to keep. A machine that never joined has
no identity, which is an ordinary state and not a fault. A machine whose
identity cannot be read is a different thing entirely, and must not take the
same path -- re-enrolling would discard the identity the mesh still believes and
need a person with a new token. Fault injection found the second case untested:
the corrupt-file test was passing on the parse check, so the read-error path had
nothing defending it. It does now.

An already-enrolled machine refuses to enrol again rather than quietly
acquiring a second identity.

What is not built is the link. Enrolment stops after verifying the broker and
generating the identity, having saved nothing, so it can be run again unchanged.

132 tests, plus 32 launcher and 9 rollback.
2026-08-29 15:38:19 +02:00

93 lines
3.6 KiB
Go

// Package link is how a node reaches the mesh: one outbound connection to the broker, and
// nothing listening on this machine.
//
// novox/hq ADR 0004: the node checks the broker's certificate against the fingerprint in its
// token *before sending anything*. That is trust on first use with the first use moved out of
// band — the token travelled by a person, so its authenticity comes from the channel it took
// rather than from anything this machine can check afterwards.
package link
import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"errors"
"fmt"
"net"
"strings"
"time"
)
// ErrWrongCertificate is what a node gets when the broker is not the one its token described.
//
// Its own error because it means something specific and alarming: either the mesh's broker was
// replaced, or this node is being pointed at something else. It is not a connection problem and
// must not be retried as one.
var ErrWrongCertificate = errors.New("the broker presented a certificate this token does not pin")
// Fingerprint is what a pin looks like: sha256 over the certificate as it arrives on the wire.
func Fingerprint(der []byte) string {
sum := sha256.Sum256(der)
return "sha256:" + hex.EncodeToString(sum[:])
}
// PinnedConfig is a TLS configuration that trusts exactly one certificate.
//
// InsecureSkipVerify is true and that is not a weakening — it is the point. The mesh's broker at
// bootstrap has a self-signed certificate and is reached at an address rather than a name, so
// there is no authority to check it against and no name to match. Chain and hostname verification
// are replaced with something stricter: this exact certificate, or nothing.
//
// The check runs in VerifyPeerCertificate, which TLS calls before the handshake completes — so a
// wrong broker is refused before this node sends anything, which is what ADR 0004 requires.
func PinnedConfig(pin string) (*tls.Config, error) {
pin = strings.TrimSpace(pin)
if !strings.HasPrefix(pin, "sha256:") || len(pin) != len("sha256:")+64 {
return nil, fmt.Errorf(
"%q is not a certificate fingerprint: it is sha256: followed by 64 hex characters", pin)
}
if _, err := hex.DecodeString(pin[len("sha256:"):]); err != nil {
return nil, fmt.Errorf("%q is not a certificate fingerprint: %w", pin, err)
}
return &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // replaced by the pin below, which is stricter
MinVersion: tls.VersionTLS12,
VerifyPeerCertificate: func(raw [][]byte, _ [][]*x509.Certificate) error {
if len(raw) == 0 {
return fmt.Errorf("%w: it presented none", ErrWrongCertificate)
}
// The leaf, which is what the pin is of. A chain is irrelevant here: nothing is
// being traced to an authority, so an intermediate matching would prove nothing.
got := Fingerprint(raw[0])
if got != pin {
return fmt.Errorf(
"%w\n expected %s\n got %s\nEither this mesh's broker was replaced, "+
"or this node is being pointed at something else. This is not a "+
"connection problem and retrying will not help",
ErrWrongCertificate, pin, got)
}
return nil
},
}, nil
}
// Dial opens a TLS connection to the broker, refusing anything but the pinned certificate.
func Dial(address, pin string, timeout time.Duration) (*tls.Conn, error) {
config, err := PinnedConfig(pin)
if err != nil {
return nil, err
}
dialer := &net.Dialer{Timeout: timeout}
conn, err := tls.DialWithDialer(dialer, "tcp", address, config)
if err != nil {
if errors.Is(err, ErrWrongCertificate) {
return nil, err
}
return nil, fmt.Errorf("cannot reach the broker at %s: %w", address, err)
}
return conn, nil
}