diff --git a/README.md b/README.md index 367f181..a74d626 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,32 @@ the one. **It does not decide.** Anything needing knowledge of another node is the control plane's, and the host never queries the mesh database. It receives declarations and applies them. +## Joining a mesh + +``` +mesh-host enrol --token --name +``` + +**The node generates its own identity** — an Ed25519 keypair whose private half never leaves the +machine. The mesh records the public half. Nothing is issued to this node; it arrives holding its +identity, and what it receives is being known. + +**The broker's certificate is checked before this machine sends anything.** The token pins a +fingerprint; the connection is refused if what answers presents anything else. That refusal has +its own error and says plainly that retrying will not help, because it does not mean the network +is down — it means the mesh was substituted, and since this host applies whatever the link +delivers, that would be the whole machine. + +There is no certificate authority involved and no hostname check. At bootstrap the broker is +self-signed and reached at an address rather than a name, so there is nothing to trace and nothing +to match. One exact certificate, or nothing, which is stricter than either. + +**An already-enrolled machine refuses to enrol again.** The mesh believes its first identity, so +replacing it is deliberate: remove the identity file first. + +**What is not built is the link itself.** Enrolment verifies the broker and generates the identity, +and then stops, having saved nothing — so it can be run again unchanged. + ## What exists today **Stages 1 and 2.** It reports what a machine is, and it applies a declaration to one. It diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 3f4f9ae..da06248 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -8,12 +8,14 @@ package main import ( "context" + "encoding/base64" "encoding/json" "errors" "flag" "fmt" "os" "os/signal" + "strings" "syscall" "text/tabwriter" "time" @@ -21,7 +23,9 @@ import ( "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/bundle" "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/identity" "github.com/novox/mesh-host/internal/inventory" + "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/profile" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" @@ -72,11 +76,13 @@ func main() { } type options struct { - json bool - timeout time.Duration - state string - dryRun bool - file string + json bool + timeout time.Duration + state string + token string + nodeName string + dryRun bool + file string } // parseArgs takes the subcommand first, then its flags. @@ -101,6 +107,8 @@ func parseArgs(args []string) (string, options, error) { set.DurationVar(&opts.timeout, "timeout", opts.timeout, "how long any single probe may take") set.StringVar(&opts.state, "state", opts.state, "where this node keeps what it knows") set.BoolVar(&opts.dryRun, "dry-run", false, "read and check the declaration, change nothing") + set.StringVar(&opts.token, "token", "", "enrol: the one-time token, carried here by a person") + set.StringVar(&opts.nodeName, "name", "", "enrol: what this machine is called in the mesh") // Parsed in a loop, because the standard library stops at the FIRST non-flag argument. // `mesh-host inventory --json` hit that once, and taking the subcommand off the front @@ -213,6 +221,9 @@ func run(ctx context.Context, command string, opts options) error { } return w.Flush() + case "enrol", "enroll": + return enrol(opts) + case "version": fmt.Println(version) return nil @@ -367,3 +378,64 @@ func runApply(ctx context.Context, opts options, d *declaration.Declaration, sou fmt.Printf("%s: applied — %d resource(s)\n", source, len(report.Outcomes)) return nil } + +// enrol joins this machine to a mesh. +// +// novox/hq 09-the-node-lifecycle: the token carries four things, the node dials the broker over +// the underlay, checks the certificate against the pin *before sending anything*, and presents +// the one-time secret together with a public key it generated itself. +// +// The mesh issues no identity. This machine arrives holding one; what it receives is being known. +func enrol(opts options) error { + tokenText, name := &opts.token, &opts.nodeName + if strings.TrimSpace(*tokenText) == "" { + return errors.New("enrol --token : the token is carried to this machine by a " + + "person, and is the only thing it needs") + } + + // Refused whole if incomplete. A token without the fingerprint would have this machine + // connect to whatever answers; without the signing key it could not tell a declaration from + // a forgery, and it applies whatever the link delivers. + token, err := identity.ParseToken(*tokenText) + if err != nil { + return err + } + + // Before anything else: an already-enrolled machine must not quietly acquire a second + // identity. The mesh believes the first one, and re-enrolling is a deliberate act that + // starts with a person issuing a new token for that node record. + identityPath := identity.Path(opts.state) + switch existing, err := identity.Load(identityPath); { + case err == nil: + return fmt.Errorf( + "this machine is already node %q. Re-enrolling replaces the identity the mesh "+ + "believes, so it is done deliberately: remove %s first", + existing.Node, identityPath) + case errors.Is(err, identity.ErrNoIdentity): + default: + return err + } + + fmt.Printf("token for broker %s\n", token.Broker) + fmt.Printf(" pinned certificate %s\n", token.Fingerprint) + fmt.Printf(" signing key %s\n", + base64.StdEncoding.EncodeToString(token.Signer)[:16]+"...") + + // The check that has to happen before this machine says anything. + conn, err := link.Dial(token.Broker, token.Fingerprint, opts.timeout) + if err != nil { + return err + } + defer conn.Close() + fmt.Println("\nthe broker presented the certificate this token pins") + + mine, err := identity.Generate(*name) + if err != nil { + return err + } + fmt.Printf("generated this node's identity: %s\n", mine.PublicBase64()) + + return errors.New("the link is not built: this machine has verified the broker and made its " + + "identity, and there is nothing yet to present them to.\n" + + "Nothing has been saved, so this can be run again unchanged") +} diff --git a/internal/identity/identity.go b/internal/identity/identity.go new file mode 100644 index 0000000..d648e94 --- /dev/null +++ b/internal/identity/identity.go @@ -0,0 +1,138 @@ +// Package identity is what this node presents to prove it is this node. +// +// novox/hq ADR 0004: the node generates a keypair, the private half never leaves the machine, and +// the mesh records the public half. The same rule the overlay keys already follow, applied to the +// node itself. +// +// The mesh issues nothing here. A node arrives at enrolment already holding its identity; what it +// receives is *being known*. So this package is the whole of a node's identity, and it is made +// before anybody is asked for anything. +package identity + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// FileName is where a node keeps its identity, beside its state. +const FileName = "identity.json" + +// Path is where the identity lives, given where the state lives. +func Path(statePath string) string { + return filepath.Join(filepath.Dir(statePath), FileName) +} + +// Identity is this node's own keypair, and the name the mesh knows it by. +type Identity struct { + // Node is the name in the mesh's records. Learned at enrolment, from the mesh — it is the one + // thing here the node does not decide for itself. + Node string `json:"node"` + + Public []byte `json:"public"` + Private []byte `json:"private"` +} + +// ErrNoIdentity means this machine has not enrolled. +// +// Not a fault: a hosted machine has a host running and no identity, and that is a real state +// (novox/hq 09-the-node-lifecycle). It is the difference between "not a node yet" and "a node +// whose identity is missing", and only the second is a problem. +var ErrNoIdentity = errors.New("this machine has no identity, so it has not joined a mesh") + +// Generate makes a new identity. The private half exists only here, from this moment. +func Generate(node string) (Identity, error) { + public, private, err := ed25519.GenerateKey(nil) + if err != nil { + return Identity{}, fmt.Errorf("cannot generate this node's identity: %w", err) + } + return Identity{Node: node, Public: public, Private: private}, nil +} + +// Sign proves this node is that node. +func (i Identity) Sign(message []byte) []byte { + return ed25519.Sign(ed25519.PrivateKey(i.Private), message) +} + +// PublicBase64 is the public half as it travels. +func (i Identity) PublicBase64() string { + return base64.StdEncoding.EncodeToString(i.Public) +} + +// Load reads this node's identity. +func Load(path string) (Identity, error) { + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return Identity{}, ErrNoIdentity + } + if err != nil { + // Never a silent absence. A machine that has an identity and cannot read it must not + // behave as one that never had one — the second re-enrols, which would discard the + // identity the mesh still believes. + return Identity{}, fmt.Errorf( + "this node has an identity at %s and cannot read it: %w. That is not the same as "+ + "having none, so it will not re-enrol on its own", path, err) + } + + var i Identity + if err := json.Unmarshal(raw, &i); err != nil { + return Identity{}, fmt.Errorf("the identity at %s is not readable: %w", path, err) + } + if len(i.Private) != ed25519.PrivateKeySize || len(i.Public) != ed25519.PublicKeySize { + return Identity{}, fmt.Errorf( + "the identity at %s is the wrong shape: %d-byte public and %d-byte private, where an "+ + "Ed25519 identity is %d and %d", + path, len(i.Public), len(i.Private), ed25519.PublicKeySize, ed25519.PrivateKeySize) + } + if strings.TrimSpace(i.Node) == "" { + return Identity{}, fmt.Errorf("the identity at %s names no node", path) + } + return i, nil +} + +// Save writes the identity, readable by nobody else. +// +// Written to a temporary file and renamed, so a machine losing power mid-write keeps the identity +// it had rather than acquiring half of one. A node cannot regenerate its way out of that: the mesh +// believes the old public key, and a new one needs a new token from a person. +func Save(path string, i Identity) error { + if len(i.Private) != ed25519.PrivateKeySize { + return errors.New("refusing to save an identity with no usable private key") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + + raw, err := json.MarshalIndent(i, "", " ") + if err != nil { + return err + } + + tmp, err := os.CreateTemp(filepath.Dir(path), ".identity-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(raw); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go new file mode 100644 index 0000000..57a0169 --- /dev/null +++ b/internal/identity/identity_test.go @@ -0,0 +1,283 @@ +package identity + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAMachineThatHasNotJoinedHasNoIdentityAndThatIsNotAFault(t *testing.T) { + // A hosted machine has a host running and no identity. That is a real state, and confusing + // it with a fault would have every fresh install look broken. + _, err := Load(Path(filepath.Join(t.TempDir(), "state.json"))) + if !errors.Is(err, ErrNoIdentity) { + t.Fatalf("a machine that never joined gave %v", err) + } +} + +func TestAnUnreadableIdentityIsNotTheSameAsHavingNone(t *testing.T) { + // The distinction that matters most here. "None" leads to enrolling; if an unreadable + // identity took that path, a node would discard the identity the mesh still believes and + // need a person with a new token to get back. + dir := t.TempDir() + path := Path(filepath.Join(dir, "state.json")) + if err := os.WriteFile(path, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := Load(path) + if err == nil { + t.Fatal("a corrupt identity loaded") + } + if errors.Is(err, ErrNoIdentity) { + t.Fatal("a corrupt identity was reported as having none; this node would re-enrol and " + + "throw away the identity the mesh believes") + } +} + +func TestWhatIsSavedIsWhatIsLoaded(t *testing.T) { + path := Path(filepath.Join(t.TempDir(), "state.json")) + made, err := Generate("workstation") + if err != nil { + t.Fatal(err) + } + if err := Save(path, made); err != nil { + t.Fatal(err) + } + + back, err := Load(path) + if err != nil { + t.Fatal(err) + } + if back.Node != made.Node || string(back.Public) != string(made.Public) || + string(back.Private) != string(made.Private) { + t.Error("the identity changed across a save and load") + } +} + +func TestTheIdentityIsNotReadableByAnybodyElse(t *testing.T) { + // It is the only secret on the machine that identifies it. A mode that let another user on + // this machine read it would make "compromise of a node is compromise of that node" false in + // the other direction — any local user could become the node. + path := Path(filepath.Join(t.TempDir(), "state.json")) + made, err := Generate("workstation") + if err != nil { + t.Fatal(err) + } + if err := Save(path, made); err != nil { + t.Fatal(err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0o077 != 0 { + t.Errorf("the identity is mode %04o; anything but 0600 lets another local user become "+ + "this node", info.Mode().Perm()) + } +} + +func TestSavingLeavesNoHalfWrittenIdentity(t *testing.T) { + // Written and renamed, so power lost mid-write keeps the old identity rather than producing + // half of one. A node cannot regenerate its way out of a broken identity — the mesh believes + // the old public key, and a new one needs a person with a new token. + dir := t.TempDir() + path := Path(filepath.Join(dir, "state.json")) + made, err := Generate("workstation") + if err != nil { + t.Fatal(err) + } + for i := 0; i < 3; i++ { + if err := Save(path, made); err != nil { + t.Fatal(err) + } + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".identity-") { + t.Errorf("a temporary file survived: %s", e.Name()) + } + } +} + +func TestAnIdentityOfTheWrongShapeIsRefused(t *testing.T) { + // The one that would load happily and fail at the moment it signs, which is during enrolment + // against a mesh, far from here. + path := Path(filepath.Join(t.TempDir(), "state.json")) + raw, err := json.Marshal(Identity{Node: "workstation", Public: []byte("short"), Private: []byte("also short")}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("an identity with a truncated key loaded") + } +} + +func TestSigningProvesTheNodeIsThatNode(t *testing.T) { + made, err := Generate("workstation") + if err != nil { + t.Fatal(err) + } + challenge := []byte("prove it") + if !ed25519.Verify(ed25519.PublicKey(made.Public), challenge, made.Sign(challenge)) { + t.Fatal("a node's own signature did not verify against the half it publishes") + } +} + +// --- the token, which the control plane writes and this parses --- + +func encodeToken(t *testing.T, body string) string { + t.Helper() + return base64.RawURLEncoding.EncodeToString([]byte(body)) +} + +func completeToken(t *testing.T) string { + t.Helper() + public, _, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(Token{ + Version: 1, Broker: "192.0.2.10:5671", + Fingerprint: "sha256:" + strings.Repeat("ab", 32), + Signer: public, Secret: "one-time", + }) + if err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(raw) +} + +func TestTheWireFormatIsExactlyTheseFieldNames(t *testing.T) { + // The contract with the control plane, which defines this format separately because the host + // requires nothing present and does not import it (novox/hq ADR 0005). There is a matching + // test on the other side. Rename a field on either and both fail, which is the point — the + // alternative is a rename that only breaks at enrolment, on a real machine. + public, _, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(Token{Version: 1, Broker: "b", Fingerprint: "f", Signer: public, Secret: "s"}) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + for _, want := range []string{"v", "broker", "fingerprint", "signer", "secret"} { + if _, ok := fields[want]; !ok { + t.Errorf("the token has no %q field; the control plane writes that name", want) + } + } + if len(fields) != 5 { + t.Errorf("the token has %d fields, expected 5: %v", len(fields), fields) + } +} + +func TestACompleteTokenParses(t *testing.T) { + got, err := ParseToken(completeToken(t)) + if err != nil { + t.Fatal(err) + } + if got.Broker != "192.0.2.10:5671" || len(got.SignerKey()) != ed25519.PublicKeySize { + t.Errorf("parsed %+v", got) + } +} + +func TestAPastedTokenTolerantOfWhitespace(t *testing.T) { + if _, err := ParseToken(" " + completeToken(t) + "\n"); err != nil { + t.Errorf("a pasted token was refused: %v", err) + } +} + +func TestAnIncompleteTokenIsRefusedWholeAndSaysWhatIsMissing(t *testing.T) { + // Not a reduced capability — an unsafe one. Without the fingerprint this node would connect + // to whatever answers; without the signing key it could not tell a declaration from a + // forgery, and it applies whatever the link delivers. + for _, c := range []struct{ body, expect string }{ + {`{"v":1,"fingerprint":"f","signer":"` + base64Key(t) + `","secret":"s"}`, "broker's address"}, + {`{"v":1,"broker":"b","signer":"` + base64Key(t) + `","secret":"s"}`, "fingerprint"}, + {`{"v":1,"broker":"b","fingerprint":"f","secret":"s"}`, "signing key"}, + {`{"v":1,"broker":"b","fingerprint":"f","signer":"` + base64Key(t) + `"}`, "one-time secret"}, + } { + _, err := ParseToken(encodeToken(t, c.body)) + if err == nil { + t.Errorf("a token missing %s was accepted", c.expect) + continue + } + if !strings.Contains(err.Error(), c.expect) { + t.Errorf("the refusal does not name %s: %v", c.expect, err) + } + } +} + +func TestATokenFromAnotherVersionIsRefused(t *testing.T) { + if _, err := ParseToken(encodeToken(t, `{"v":99,"broker":"b","fingerprint":"f","secret":"s"}`)); err == nil { + t.Fatal("a token from an unknown version was accepted") + } +} + +func TestGarbageIsRefused(t *testing.T) { + for _, bad := range []string{"", "!!!not base64!!!", "aGVsbG8"} { + if _, err := ParseToken(bad); err == nil { + t.Errorf("%q parsed as a token", bad) + } + } +} + +func base64Key(t *testing.T) string { + t.Helper() + public, _, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(public) +} + +func TestAnIdentityThatCannotBeReadIsNotReportedAsAbsent(t *testing.T) { + // The other half of the distinction above, and the one that was untested: a file that exists + // and cannot be read. The corrupt case is caught when it fails to parse; this one never gets + // that far, so it needs its own check — and without it a permissions accident would look + // exactly like a machine that has never joined, and the node would enrol again and discard + // the identity the mesh still believes. + if os.Geteuid() == 0 { + t.Skip("running as root, which can read anything") + } + path := Path(filepath.Join(t.TempDir(), "state.json")) + made, err := Generate("workstation") + if err != nil { + t.Fatal(err) + } + if err := Save(path, made); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o000); err != nil { + t.Fatal(err) + } + + _, err = Load(path) + if err == nil { + t.Fatal("an unreadable identity loaded") + } + if errors.Is(err, ErrNoIdentity) { + t.Fatal("an unreadable identity was reported as having none; this node would re-enrol " + + "and throw away the identity the mesh believes") + } + if !strings.Contains(err.Error(), "not the same as") { + t.Errorf("the error does not say why this is different from having none: %v", err) + } +} diff --git a/internal/identity/token.go b/internal/identity/token.go new file mode 100644 index 0000000..7ea0dff --- /dev/null +++ b/internal/identity/token.go @@ -0,0 +1,74 @@ +package identity + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +// Token is what a person carries to a machine that is joining. +// +// novox/hq ADR 0004 — four things: where the broker is, what certificate to expect there, whose +// signature to believe afterwards, and a one-time right to join. +// +// THIS IS A WIRE FORMAT SHARED WITH THE CONTROL PLANE, which writes it. The two definitions are +// separate on purpose — the host requires nothing present and does not import the control plane — +// so they are held together by a test on each side asserting the exact field names rather than by +// a shared type. If a field is renamed here and not there, that test fails on both sides. +type Token struct { + Version int `json:"v"` + Broker string `json:"broker,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Signer []byte `json:"signer,omitempty"` + Secret string `json:"secret"` +} + +// ParseToken reads a token a person pasted. +// +// Every refusal here says *this is not a token* rather than *this is the wrong token*. The +// difference matters once there is a mesh: a host must tell "this is not from the mesh I joined" +// apart from "this is malformed" (novox/hq ADR 0004), and the first is a signature check later, +// not a parse failure here. +func ParseToken(encoded string) (Token, error) { + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil { + return Token{}, fmt.Errorf("this is not a token: %w", err) + } + var t Token + if err := json.Unmarshal(raw, &t); err != nil { + return Token{}, fmt.Errorf("this is not a token: %w", err) + } + if t.Version != 1 { + return Token{}, fmt.Errorf( + "this token says it is version %d, and this host understands version 1", t.Version) + } + + var missing []string + if strings.TrimSpace(t.Broker) == "" { + missing = append(missing, "the broker's address") + } + if strings.TrimSpace(t.Fingerprint) == "" { + missing = append(missing, "the broker certificate's fingerprint") + } + if len(t.Signer) != ed25519.PublicKeySize { + missing = append(missing, "the control plane's signing key") + } + if strings.TrimSpace(t.Secret) == "" { + missing = append(missing, "the one-time secret") + } + if len(missing) > 0 { + // Refused whole rather than used partially. A token missing the fingerprint would have + // this node connect to whatever answers at that address, and one missing the signing key + // would leave it unable to tell a declaration from a forgery — so an incomplete token is + // not a reduced capability, it is an unsafe one. + return Token{}, fmt.Errorf( + "this token is missing %s, so it cannot be used to join anything", + strings.Join(missing, ", ")) + } + return t, nil +} + +// SignerKey is the control plane's public signing key, as a key. +func (t Token) SignerKey() ed25519.PublicKey { return ed25519.PublicKey(t.Signer) } diff --git a/internal/link/pinned.go b/internal/link/pinned.go new file mode 100644 index 0000000..4e12f10 --- /dev/null +++ b/internal/link/pinned.go @@ -0,0 +1,92 @@ +// 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 +} diff --git a/internal/link/pinned_test.go b/internal/link/pinned_test.go new file mode 100644 index 0000000..792b8ec --- /dev/null +++ b/internal/link/pinned_test.go @@ -0,0 +1,170 @@ +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") + } +}