Files

412 lines
14 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)
}
// 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)
}
}