Found by raising a mesh end to end for the first time. Enrolment's own help says the token "is the only thing it needs", and it also needed --name, with no default. Without it the failure is: cannot reach the broker at 192.0.2.10:5671 as : username or password not allowed An empty username, and nothing about the cause. The node cannot work its own name out. The broker account it authenticates as is named after it and exists before this machine has been told anything, so the name has to arrive with the rest. It is not a secret and the issuer already knows it. --name stays, as an override for a token issued before the name travelled in one, and says so when it is needed rather than failing at the broker. Also corrects the bundle example, which claimed to stop before the control plane runs and has raised one for some time. A comment about what something does not do is a comment nobody updates.
399 lines
13 KiB
Go
399 lines
13 KiB
Go
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")
|
|
}
|
|
}
|
|
|
|
// joined is an identity as it exists after enrolment, which is the only kind ever saved:
|
|
// Generate makes the keypair, and the mesh supplies everything under Membership.
|
|
func joined(t *testing.T, name string) Identity {
|
|
t.Helper()
|
|
made, err := Generate(name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
signer, _, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
made.Membership = Membership{
|
|
Broker: "192.0.2.10:5671",
|
|
Fingerprint: "sha256:" + strings.Repeat("ab", 32),
|
|
Signer: signer,
|
|
Password: "this node's own",
|
|
}
|
|
return made
|
|
}
|
|
|
|
func TestSaveRefusesWhatLoadWouldRefuse(t *testing.T) {
|
|
// The two must agree, or a caller can write a file that cannot be read back — and it would
|
|
// be read back on the next start, on a machine nobody is watching, by which time the token
|
|
// that could have fixed it is spent.
|
|
path := Path(filepath.Join(t.TempDir(), "state.json"))
|
|
unenrolled, err := Generate("workstation")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := Save(path, unenrolled); err == nil {
|
|
t.Fatal("an identity with no membership was saved; Load will not accept it")
|
|
}
|
|
if _, err := os.Stat(path); err == nil {
|
|
t.Error("the refused identity was written anyway")
|
|
}
|
|
}
|
|
|
|
func TestWhatIsSavedIsWhatIsLoaded(t *testing.T) {
|
|
path := Path(filepath.Join(t.TempDir(), "state.json"))
|
|
made := joined(t, "workstation")
|
|
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")
|
|
}
|
|
if back.Membership.Broker != made.Membership.Broker ||
|
|
back.Membership.Fingerprint != made.Membership.Fingerprint ||
|
|
back.Membership.Password != made.Membership.Password ||
|
|
string(back.Membership.Signer) != string(made.Membership.Signer) {
|
|
t.Error("the membership changed across a save and load; this node could not come back")
|
|
}
|
|
}
|
|
|
|
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"))
|
|
if err := Save(path, joined(t, "workstation")); 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 := joined(t, "workstation")
|
|
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"))
|
|
if err := Save(path, joined(t, "workstation")); 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)
|
|
}
|
|
}
|
|
|
|
func TestASealingKeyOpensOnlyWhatWasSealedToIt(t *testing.T) {
|
|
mine, err := GenerateSealingKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
theirs, err := GenerateSealingKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sealed, err := Seal(mine.Public, []byte("hunter2"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := mine.Unseal(sealed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got) != "hunter2" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
if _, err := theirs.Unseal(sealed); err == nil {
|
|
t.Fatal("another node opened it")
|
|
}
|
|
}
|
|
|
|
func TestSealingTheSameValueTwiceLooksDifferent(t *testing.T) {
|
|
// Sealed boxes are randomised, so an observer cannot tell that two nodes were given the same
|
|
// password, nor that a rotation changed nothing. Worth asserting because the alternative is
|
|
// a subtle leak nobody would look for.
|
|
key, _ := GenerateSealingKey()
|
|
first, _ := Seal(key.Public, []byte("same"))
|
|
second, _ := Seal(key.Public, []byte("same"))
|
|
if first == second {
|
|
t.Fatal("sealing is deterministic, so equal secrets are visible as equal blobs")
|
|
}
|
|
}
|
|
|
|
func TestANodeWithNoSealingKeySaysWhatToDo(t *testing.T) {
|
|
// Rather than making one. A key the mesh was never told about is a key nothing can be sealed
|
|
// to, so a node that quietly created one would look fine and receive nothing for ever.
|
|
_, err := LoadSealingKey(t.TempDir() + "/absent.key")
|
|
if err == nil {
|
|
t.Fatal("a sealing key appeared out of nowhere")
|
|
}
|
|
if !strings.Contains(err.Error(), "join again") {
|
|
t.Fatalf("the failure does not say what to do: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestASealingKeyOnDiskSurvivesATrailingNewline(t *testing.T) {
|
|
// It is written with one, the way every other key file here is, and reading it back has to
|
|
// cope — otherwise the key works until the first restart.
|
|
key, _ := GenerateSealingKey()
|
|
path := t.TempDir() + "/sealing.key"
|
|
if err := os.WriteFile(path, []byte(key.Private+"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back, err := LoadSealingKey(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if back.Public != key.Public {
|
|
t.Fatalf("a round trip through the disk changed the key")
|
|
}
|
|
}
|
|
|
|
func TestATokenSaysWhatTheMeshCallsThisMachine(t *testing.T) {
|
|
// The node cannot work its own name out. The broker account it authenticates as is named
|
|
// after it and exists before this machine has been told anything — so without the name in the
|
|
// token, enrolment is a connection refused with an empty username, which names nothing about
|
|
// the cause. That is exactly how the first end-to-end raise went.
|
|
raw := base64.RawURLEncoding.EncodeToString([]byte(
|
|
`{"v":1,"node":"anchor","broker":"192.0.2.10:5671",` +
|
|
`"fingerprint":"sha256:` + strings.Repeat("ab", 32) + `",` +
|
|
`"signer":"` + base64.StdEncoding.EncodeToString(make([]byte, 32)) + `",` +
|
|
`"secret":"a-one-time-secret"}`))
|
|
token, err := ParseToken(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if token.Node != "anchor" {
|
|
t.Fatalf("the name did not survive the token: %q", token.Node)
|
|
}
|
|
}
|