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.
This commit is contained in:
@@ -37,6 +37,7 @@ mesh-control node list the nodes this mesh knows about
|
||||
mesh-control token issue --node <name> a one-time right to join, for an existing record
|
||||
mesh-control token issue --new <name> create the record and issue for it
|
||||
mesh-control identity show this control plane's signing key
|
||||
mesh-control broker show where the broker is, and what to expect there
|
||||
mesh-control version what this binary is
|
||||
```
|
||||
|
||||
@@ -65,9 +66,16 @@ travels in every token. A node believes a declaration because it carries a signa
|
||||
transitive, so a compromised broker could forge declarations, and since the host applies whatever
|
||||
the link delivers that is the whole machine.
|
||||
|
||||
**Still missing: the broker's address and its certificate fingerprint.** Both are step 5 of the
|
||||
substrate bootstrap and neither exists. `token issue` prints the token **and names what is
|
||||
missing**, rather than producing something that looks complete and cannot be used.
|
||||
**All four parts are built.** Given `MESH_BROKER_ADDRESS` and `MESH_BROKER_CERTIFICATE`, a token
|
||||
carries everything ADR 0004 requires. Without them it carries two, and `token issue` prints it
|
||||
**and names what is missing** rather than producing something that looks complete and cannot be
|
||||
used.
|
||||
|
||||
**The fingerprint is derived from the certificate, never configured.** A configured one 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. It is computed over the
|
||||
DER bytes — what a client actually sees on the wire — so the same certificate re-wrapped with
|
||||
different line endings still produces the same pin.
|
||||
|
||||
### Two contexts, and the rule between them is real
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-control/internal/broker"
|
||||
"github.com/novox/mesh-control/internal/identity"
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/store"
|
||||
@@ -64,6 +65,8 @@ func run() error {
|
||||
return tokenCommand(ctx, args[1:])
|
||||
case "identity":
|
||||
return identityCommand(ctx, args[1:])
|
||||
case "broker":
|
||||
return brokerCommand(args[1:])
|
||||
case "version":
|
||||
fmt.Println(version)
|
||||
return nil
|
||||
@@ -85,6 +88,7 @@ func usage() {
|
||||
token issue --node <name> a one-time right to join, for an existing record
|
||||
token issue --new <name> create the record and issue for it
|
||||
identity show this control plane's signing key
|
||||
broker show where the broker is, and what to expect there
|
||||
version what this binary is
|
||||
|
||||
Each context reaches its own store through its own credential (novox/hq ADR 0008), named
|
||||
@@ -250,6 +254,17 @@ func tokenCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
made := token.Token{Signer: key.Public, Secret: issued.Secret}
|
||||
|
||||
// Absent is a state, not a failure: a control plane can hold records and a key before it has
|
||||
// a broker. What it cannot do is issue a token anybody could use, and Missing() says so.
|
||||
known, err := broker.FromEnvironment()
|
||||
switch {
|
||||
case err == nil:
|
||||
made.Broker, made.Fingerprint = known.Address, known.Fingerprint
|
||||
case errors.Is(err, broker.ErrNotConfigured):
|
||||
default:
|
||||
return err
|
||||
}
|
||||
encoded, err := made.Encode()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -264,8 +279,8 @@ func tokenCommand(ctx context.Context, args []string) error {
|
||||
for _, m := range missing {
|
||||
fmt.Printf(" - %s\n", m)
|
||||
}
|
||||
fmt.Println("\nThe broker and its certificate are step 5 of the substrate bootstrap and " +
|
||||
"do not exist yet\n(novox/hq 07-the-substrate). The signing key above is real.")
|
||||
fmt.Printf("\nSet %s and %s once the broker is raised.\n",
|
||||
broker.AddressVar, broker.CertificateVar)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -305,3 +320,25 @@ func identityCommand(ctx context.Context, args []string) error {
|
||||
"declaration because it carries a signature this key made (novox/hq ADR 0004).\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func brokerCommand(args []string) error {
|
||||
if len(args) == 0 || args[0] != "show" {
|
||||
return errors.New("broker show")
|
||||
}
|
||||
known, err := broker.FromEnvironment()
|
||||
if errors.Is(err, broker.ErrNotConfigured) {
|
||||
fmt.Printf("no broker configured. Set %s and %s.\n\n"+
|
||||
"Until then tokens carry the signing key and the one-time secret, and say what they\n"+
|
||||
"are missing. They cannot be used to join.\n",
|
||||
broker.AddressVar, broker.CertificateVar)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("address %s\n", known.Address)
|
||||
fmt.Printf("fingerprint %s\n", known.Fingerprint)
|
||||
fmt.Print("\nThe fingerprint is computed from the certificate on disk, never configured. A\n" +
|
||||
"node checks it before sending anything (novox/hq ADR 0004).\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeCertificate puts a real self-signed certificate on disk and returns its path and the
|
||||
// DER bytes, which is what a TLS client would see on the wire.
|
||||
func writeCertificate(t *testing.T) (string, []byte) {
|
||||
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(24 * time.Hour),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "tls.crt")
|
||||
if err := os.WriteFile(path, pem.EncodeToMemory(
|
||||
&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path, der
|
||||
}
|
||||
|
||||
func TestTheFingerprintIsOverWhatAClientSees(t *testing.T) {
|
||||
// A node computes this from the certificate the broker presents, which is DER on the wire.
|
||||
// Hashing the PEM text instead would mean the same certificate, re-wrapped with different
|
||||
// line endings, produced a different pin — and every token issued around that moment would
|
||||
// send a node to something it refuses to talk to.
|
||||
path, der := writeCertificate(t)
|
||||
|
||||
got, err := FingerprintOf(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(der)
|
||||
want := "sha256:" + hex.EncodeToString(sum[:])
|
||||
if got != want {
|
||||
t.Errorf("fingerprint is %s, and a client computing it from the wire gets %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReWrappingTheSameCertificateDoesNotChangeThePin(t *testing.T) {
|
||||
// The property the test above protects, stated directly: same certificate, different file
|
||||
// formatting, same pin.
|
||||
path, der := writeCertificate(t)
|
||||
first, err := FingerprintOf(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rewrapped := filepath.Join(t.TempDir(), "same.crt")
|
||||
body := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
if err := os.WriteFile(rewrapped, append([]byte("\n\n"), body...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := FingerprintOf(rewrapped)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != second {
|
||||
t.Errorf("the same certificate produced two pins:\n %s\n %s", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPrivateKeyIsNotACertificate(t *testing.T) {
|
||||
// The mistake somebody makes once: pointing this at tls.key. Left unchecked it would produce
|
||||
// a confident pin over the wrong file, and every node would refuse the broker.
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
der, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "tls.key")
|
||||
if err := os.WriteFile(path, pem.EncodeToMemory(
|
||||
&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = FingerprintOf(path)
|
||||
if err == nil {
|
||||
t.Fatal("a private key was fingerprinted as if it were a certificate")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "private key") {
|
||||
t.Errorf("the error does not say what the file actually is: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMalformedCertificateIsRefusedRatherThanHashed(t *testing.T) {
|
||||
// Bytes wrapped in the right PEM header are not a certificate. Hashing them would produce a
|
||||
// pin that matches nothing, and the failure would arrive on a node instead of here.
|
||||
path := filepath.Join(t.TempDir(), "broken.crt")
|
||||
if err := os.WriteFile(path, pem.EncodeToMemory(
|
||||
&pem.Block{Type: "CERTIFICATE", Bytes: []byte("not a certificate")}), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := FingerprintOf(path); err == nil {
|
||||
t.Fatal("malformed bytes were accepted as a certificate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoBrokerIsAStateAndNotAFailure(t *testing.T) {
|
||||
t.Setenv(AddressVar, "")
|
||||
t.Setenv(CertificateVar, "")
|
||||
if _, err := FromEnvironment(); !errors.Is(err, ErrNotConfigured) {
|
||||
t.Fatalf("expected ErrNotConfigured, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAddressWithoutACertificateIsRefused(t *testing.T) {
|
||||
// Worse than neither: a token with somewhere to connect and nothing to check would have a
|
||||
// node trust whatever answers at that address.
|
||||
//
|
||||
// Asserted on which refusal fired. Without the check this still fails a line later, trying to
|
||||
// read a certificate at the empty path — so a test asking only "was there an error" passes
|
||||
// with the guard deleted. It was written that way first and confirmed to defend nothing.
|
||||
t.Setenv(AddressVar, "192.0.2.10:5671")
|
||||
t.Setenv(CertificateVar, "")
|
||||
|
||||
_, err := FromEnvironment()
|
||||
if err == nil || errors.Is(err, ErrNotConfigured) {
|
||||
t.Fatalf("an address with no certificate was accepted: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "must be set together") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACertificateWithoutAnAddressIsRefused(t *testing.T) {
|
||||
path, _ := writeCertificate(t)
|
||||
t.Setenv(AddressVar, "")
|
||||
t.Setenv(CertificateVar, path)
|
||||
|
||||
_, err := FromEnvironment()
|
||||
if err == nil || errors.Is(err, ErrNotConfigured) {
|
||||
t.Fatalf("a certificate with no address was accepted: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "must be set together") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBothTogetherGiveABroker(t *testing.T) {
|
||||
path, _ := writeCertificate(t)
|
||||
t.Setenv(AddressVar, "192.0.2.10:5671")
|
||||
t.Setenv(CertificateVar, path)
|
||||
|
||||
known, err := FromEnvironment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if known.Address != "192.0.2.10:5671" || !strings.HasPrefix(known.Fingerprint, "sha256:") {
|
||||
t.Errorf("got %+v", known)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user