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:
2026-08-29 15:38:19 +02:00
parent a740959cb0
commit 65d896d96e
7 changed files with 860 additions and 5 deletions
+283
View File
@@ -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)
}
}