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.
This commit is contained in:
@@ -20,6 +20,32 @@ the one.
|
|||||||
**It does not decide.** Anything needing knowledge of another node is the control plane's, and
|
**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.
|
the host never queries the mesh database. It receives declarations and applies them.
|
||||||
|
|
||||||
|
## Joining a mesh
|
||||||
|
|
||||||
|
```
|
||||||
|
mesh-host enrol --token <token> --name <what this machine is called>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
## What exists today
|
||||||
|
|
||||||
**Stages 1 and 2.** It reports what a machine is, and it applies a declaration to one. It
|
**Stages 1 and 2.** It reports what a machine is, and it applies a declaration to one. It
|
||||||
|
|||||||
+77
-5
@@ -8,12 +8,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
@@ -21,7 +23,9 @@ import (
|
|||||||
"github.com/novox/mesh-host/internal/apply"
|
"github.com/novox/mesh-host/internal/apply"
|
||||||
"github.com/novox/mesh-host/internal/bundle"
|
"github.com/novox/mesh-host/internal/bundle"
|
||||||
"github.com/novox/mesh-host/internal/declaration"
|
"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/inventory"
|
||||||
|
"github.com/novox/mesh-host/internal/link"
|
||||||
"github.com/novox/mesh-host/internal/profile"
|
"github.com/novox/mesh-host/internal/profile"
|
||||||
"github.com/novox/mesh-host/internal/store"
|
"github.com/novox/mesh-host/internal/store"
|
||||||
"github.com/novox/mesh-host/internal/system"
|
"github.com/novox/mesh-host/internal/system"
|
||||||
@@ -72,11 +76,13 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type options struct {
|
type options struct {
|
||||||
json bool
|
json bool
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
state string
|
state string
|
||||||
dryRun bool
|
token string
|
||||||
file string
|
nodeName string
|
||||||
|
dryRun bool
|
||||||
|
file string
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseArgs takes the subcommand first, then its flags.
|
// 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.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.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.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.
|
// 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
|
// `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()
|
return w.Flush()
|
||||||
|
|
||||||
|
case "enrol", "enroll":
|
||||||
|
return enrol(opts)
|
||||||
|
|
||||||
case "version":
|
case "version":
|
||||||
fmt.Println(version)
|
fmt.Println(version)
|
||||||
return nil
|
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))
|
fmt.Printf("%s: applied — %d resource(s)\n", source, len(report.Outcomes))
|
||||||
return nil
|
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 <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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user