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.
75 lines
2.8 KiB
Go
75 lines
2.8 KiB
Go
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) }
|