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) } // novox/hq ADR 0100: an adopted node's token says so, and a converged one's is unchanged. raw, err = json.Marshal(Token{Version: 1, Secret: "s", Adopted: true}) if err != nil { t.Fatal(err) } fields = map[string]any{} if err := json.Unmarshal(raw, &fields); err != nil { t.Fatal(err) } if fields["adopted"] != true { t.Errorf("an adopted token does not say \"adopted\": %v", 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) } }