Save and Load must agree, and did not

Pushed a failing test in the last commit -- my own gate reported it and I read
the count rather than the result. The failure was real and worth having.

Adding the membership requirement to Load made Generate produce an identity that
Save would write and Load would then refuse. A file that cannot be read back is
the worst shape this could take: it is read back on the next start, on a machine
nobody is watching, and by then the token that could have fixed it is spent.

Save now refuses exactly what Load refuses, and writes nothing when it does. The
round-trip test covers the membership too, since that is the half that lets a
node come back on its own.
This commit is contained in:
2026-08-29 16:24:57 +02:00
parent a488c76b5e
commit c192572f74
2 changed files with 58 additions and 18 deletions
+48 -18
View File
@@ -40,12 +40,47 @@ func TestAnUnreadableIdentityIsNotTheSameAsHavingNone(t *testing.T) {
}
}
func TestWhatIsSavedIsWhatIsLoaded(t *testing.T) {
path := Path(filepath.Join(t.TempDir(), "state.json"))
made, err := Generate("workstation")
// 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)
}
@@ -58,6 +93,12 @@ func TestWhatIsSavedIsWhatIsLoaded(t *testing.T) {
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) {
@@ -65,11 +106,7 @@ func TestTheIdentityIsNotReadableByAnybodyElse(t *testing.T) {
// 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 {
if err := Save(path, joined(t, "workstation")); err != nil {
t.Fatal(err)
}
@@ -89,10 +126,7 @@ func TestSavingLeavesNoHalfWrittenIdentity(t *testing.T) {
// 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)
}
made := joined(t, "workstation")
for i := 0; i < 3; i++ {
if err := Save(path, made); err != nil {
t.Fatal(err)
@@ -258,18 +292,14 @@ func TestAnIdentityThatCannotBeReadIsNotReportedAsAbsent(t *testing.T) {
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 {
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)
_, err := Load(path)
if err == nil {
t.Fatal("an unreadable identity loaded")
}