novox/hq 04-ISSUES/146. Each was right while the mesh ran on the previous broker, and nothing has raised a foundation since it changed. The bus's certificate is made by the program that needs it rather than by openssl inside the broker's image — the bus's image is Alpine with a shell and no openssl, so the step exited 127 and no mesh could be raised. Self-signed as before and on purpose; --user 0:0 because the volume is root's and the control plane's image runs as nobody. Enrolment no longer opens a raw TLS connection to check the pin: NATS speaks its own protocol and upgrades afterwards, so the handshake met a plaintext greeting. The client that presents the token carries the same pinned config and verifies inside its own handshake, so the secret still leaves only after the certificate is checked. The raw dial stays as what its tests prove, and is no longer a path anything takes. And the token says which bus it is for. Empty meant 'whatever the mesh runs today' while two buses existed and became a refusal the moment one did. It now stops at the bus's user list, which is the genesis half of 146.
104 lines
4.3 KiB
Go
104 lines
4.3 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
|
|
}
|
|
|
|
// dialPinned completes a TLS handshake against an address, refusing anything but the pinned
|
|
// certificate.
|
|
//
|
|
// **Not how the bus is reached, and it used to be** (novox/hq 04-ISSUES/146). Enrolment opened one
|
|
// of these before it said anything, which was right while the broker answered TLS immediately and
|
|
// wrong the moment the mesh moved to a bus that speaks its own protocol first. The pin itself was
|
|
// never the problem — PinnedConfig is what the NATS client is given, and the verification runs
|
|
// inside the handshake that client performs.
|
|
//
|
|
// It stays here because this is where the pin is proven: the tests beside it run a real TLS server
|
|
// and assert that a wrong certificate is refused before a byte of application data is sent. What it
|
|
// must not become again is something a caller uses to reach the bus.
|
|
func dialPinned(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 %s: %w", address, err)
|
|
}
|
|
return conn, nil
|
|
}
|