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.
171 lines
4.9 KiB
Go
171 lines
4.9 KiB
Go
package link
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"errors"
|
|
"math/big"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// A real TLS server with a real self-signed certificate. Not a fake: what is being tested is that
|
|
// Go's TLS stack calls this verification before the handshake completes and that a wrong
|
|
// certificate is refused there — a fake would assert that the fake refuses it
|
|
// (novox/hq ADR 0017).
|
|
func server(t *testing.T) (address string, fingerprint string) {
|
|
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(time.Hour),
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
|
|
Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}},
|
|
MinVersion: tls.VersionTLS12,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { listener.Close() })
|
|
|
|
go func() {
|
|
for {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func() {
|
|
// Complete the handshake, then close. Enough for a client to have checked.
|
|
_ = conn.(*tls.Conn).Handshake()
|
|
conn.Close()
|
|
}()
|
|
}
|
|
}()
|
|
return listener.Addr().String(), Fingerprint(der)
|
|
}
|
|
|
|
func TestTheRightBrokerIsAccepted(t *testing.T) {
|
|
address, pin := server(t)
|
|
conn, err := Dial(address, pin, 5*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("the broker its token describes was refused: %v", err)
|
|
}
|
|
conn.Close()
|
|
}
|
|
|
|
func TestADifferentBrokerIsRefused(t *testing.T) {
|
|
// The case the pin exists for: something else answering at that address. Since the host
|
|
// applies whatever the link delivers, connecting to the wrong mesh is the whole machine.
|
|
address, _ := server(t)
|
|
_, other := server(t)
|
|
|
|
_, err := Dial(address, other, 5*time.Second)
|
|
if err == nil {
|
|
t.Fatal("a broker presenting a different certificate was accepted")
|
|
}
|
|
if !errors.Is(err, ErrWrongCertificate) {
|
|
t.Fatalf("refused, but not as a wrong certificate: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "retrying will not help") {
|
|
t.Error("the error reads like a connection problem; this one must not be retried")
|
|
}
|
|
}
|
|
|
|
func TestNothingIsSentToTheWrongBroker(t *testing.T) {
|
|
// ADR 0004 requires the check to happen *before* anything is sent. Asserted by counting what
|
|
// the wrong server received: a handshake, and no application bytes.
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
template := x509.Certificate{
|
|
SerialNumber: big.NewInt(2),
|
|
Subject: pkix.Name{CommonName: "impostor"},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(time.Hour),
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
|
|
Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}},
|
|
MinVersion: tls.VersionTLS12,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer listener.Close()
|
|
|
|
received := make(chan int, 1)
|
|
go func() {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
received <- -1
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
buf := make([]byte, 512)
|
|
n, _ := conn.Read(buf)
|
|
received <- n
|
|
}()
|
|
|
|
// A pin for a certificate this server does not have.
|
|
_, elsewhere := server(t)
|
|
if _, err := Dial(listener.Addr().String(), elsewhere, 5*time.Second); err == nil {
|
|
t.Fatal("the impostor was accepted")
|
|
}
|
|
if n := <-received; n > 0 {
|
|
t.Errorf("%d application byte(s) reached a broker that failed the pin", n)
|
|
}
|
|
}
|
|
|
|
func TestAMalformedPinIsRefusedBeforeConnecting(t *testing.T) {
|
|
// Caught here rather than at the handshake, so a mistyped token fails while a person is
|
|
// looking at it.
|
|
for _, bad := range []string{"", "sha256:short", strings.Repeat("a", 64),
|
|
"sha256:" + strings.Repeat("z", 64), "md5:" + strings.Repeat("a", 64)} {
|
|
if _, err := PinnedConfig(bad); err == nil {
|
|
t.Errorf("%q was accepted as a fingerprint", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnUnreachableBrokerIsAnOrdinaryFailure(t *testing.T) {
|
|
// Must not read as a wrong certificate: one is a network problem worth retrying, the other
|
|
// means the mesh was substituted.
|
|
_, pin := server(t)
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
address := listener.Addr().String()
|
|
listener.Close()
|
|
|
|
_, err = Dial(address, pin, 2*time.Second)
|
|
if err == nil {
|
|
t.Fatal("dialling a closed port succeeded")
|
|
}
|
|
if errors.Is(err, ErrWrongCertificate) {
|
|
t.Error("an unreachable broker was reported as presenting the wrong certificate")
|
|
}
|
|
}
|