Unify trunk on main: initialization → main #3
@@ -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 <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
|
||||
|
||||
**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 (
|
||||
"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 <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