From a51dc144a157582541762b9df994156d15d29922 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 20:04:27 +0000 Subject: [PATCH] Test the key keeper (issue 1469), part 2 of 6 --- go/forgekey/keykeeper_test.go | 229 ++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 go/forgekey/keykeeper_test.go diff --git a/go/forgekey/keykeeper_test.go b/go/forgekey/keykeeper_test.go new file mode 100644 index 0000000..a52e176 --- /dev/null +++ b/go/forgekey/keykeeper_test.go @@ -0,0 +1,229 @@ +package forgekey + +import ( + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Issue 955, area S1: the key the machine's account makes for the forge, and its entry in the keys state. + +type fakeState struct{ puts map[string][]byte } + +func (f *fakeState) Put(key string, value any) (uint64, error) { + raw, _ := json.Marshal(value) + if f.puts == nil { + f.puts = map[string][]byte{} + } + f.puts[key] = raw + return uint64(len(f.puts)), nil +} + +// ed25519Line is a public key line with a well-formed ed25519 blob. +func ed25519Line(seed byte, comment string) string { + blob := []byte{} + for _, part := range [][]byte{[]byte("ssh-ed25519"), make([]byte, 32)} { + part2 := append([]byte(nil), part...) + if len(part2) == 32 { + part2[0] = seed + } + l := make([]byte, 4) + binary.BigEndian.PutUint32(l, uint32(len(part2))) + blob = append(append(blob, l...), part2...) + } + return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(blob) + " " + comment +} + +// keygenRunner stands in for ssh-keygen -t ed25519: it writes the pair the command names. +func keygenRunner(calls *[][]string) Runner { + return func(ctx context.Context, env []string, name string, args ...string) Ran { + *calls = append(*calls, append([]string{name}, args...)) + var file, comment string + for i, a := range args { + switch a { + case "-f": + file = args[i+1] + case "-C": + comment = args[i+1] + } + } + if name != "ssh-keygen" || file == "" { + return Ran{Status: 1, Stderr: "unexpected"} + } + os.WriteFile(file, []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nPRIVATE-SECRET\n"), 0o600) + os.WriteFile(file+".pub", []byte(ed25519Line(7, comment)+"\n"), 0o644) + return Ran{} + } +} + +func newKeeper(t *testing.T, home string, st *fakeState, calls *[][]string) *KeyKeeper { + return &KeyKeeper{Home: home, Node: "g14", Account: "nox", Kind: KindMachine, Run: keygenRunner(calls), State: st, + Now: func() time.Time { return time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) }} +} + +func TestKeeperMakesTheKeyAndWritesItsEntry(t *testing.T) { + home := t.TempDir() + st, calls := &fakeState{}, &[][]string{} + if err := newKeeper(t, home, st, calls).Once(context.Background()); err != nil { + t.Fatal(err) + } + raw, ok := st.puts["g14.nox"] + if !ok || len(st.puts) != 1 { + t.Fatalf("entries = %v, want one under g14.nox", st.puts) + } + var e map[string]string + json.Unmarshal(raw, &e) + if e["node"] != "g14" || e["account"] != "nox" || e["kind"] != "machine" || e["at"] != "2026-10-16T12:00:00Z" { + t.Errorf("entry = %v", e) + } + if !strings.HasPrefix(e["public"], "ssh-ed25519 ") || !strings.HasSuffix(e["public"], " nox@g14 (mesh)") { + t.Errorf("public = %q", e["public"]) + } + if !strings.HasPrefix(e["fingerprint"], "SHA256:") { + t.Errorf("fingerprint = %q", e["fingerprint"]) + } + if strings.Contains(strings.ToUpper(string(raw)), "PRIVATE") { + t.Errorf("the entry carries private material: %s", raw) + } + cmd := strings.Join((*calls)[0], " ") + for _, want := range []string{"-t ed25519", "-N ", "-C nox@g14 (mesh)", filepath.Join(home, ".ssh", "mesh_ed25519")} { + if !strings.Contains(cmd, want) { + t.Errorf("ssh-keygen call %q lacks %q", cmd, want) + } + } +} + +func TestKeeperKeepsAnExistingKeyByteForByte(t *testing.T) { + home := t.TempDir() + os.MkdirAll(filepath.Join(home, ".ssh"), 0o700) + priv, pub := filepath.Join(home, ".ssh", "mesh_ed25519"), filepath.Join(home, ".ssh", "mesh_ed25519.pub") + os.WriteFile(priv, []byte("mine"), 0o600) + os.WriteFile(pub, []byte(ed25519Line(9, "nox@g14 (mesh)")+"\n"), 0o644) + st, calls := &fakeState{}, &[][]string{} + k := newKeeper(t, home, st, calls) + if err := k.Once(context.Background()); err != nil { + t.Fatal(err) + } + if len(*calls) != 0 { + t.Errorf("ssh-keygen ran over an existing key: %v", *calls) + } + if b, _ := os.ReadFile(priv); string(b) != "mine" { + t.Errorf("private file changed: %q", b) + } + if _, ok := st.puts["g14.nox"]; !ok { + t.Errorf("no entry for the existing key") + } + // A second round with nothing changed writes nothing. + delete(st.puts, "g14.nox") + k.Once(context.Background()) + if len(st.puts) != 0 { + t.Errorf("an unchanged key was put again") + } +} + +func TestKeeperNeverTouchesTheOperatorsOwnKeys(t *testing.T) { + home := t.TempDir() + os.MkdirAll(filepath.Join(home, ".ssh"), 0o700) + own := filepath.Join(home, ".ssh", "id_ed25519") + os.WriteFile(own, []byte("operator"), 0o600) + st, calls := &fakeState{}, &[][]string{} + newKeeper(t, home, st, calls).Once(context.Background()) + if b, _ := os.ReadFile(own); string(b) != "operator" { + t.Errorf("the operator's key changed") + } +} + +// 1469/AC1: an empty home, kind builder, account mesh-build-go: ssh-keygen once, for the one file, and the entry +// carries the kind, the public half and its fingerprint. +func TestBuilderKeyIsMadeOnceAndPutWithItsKind(t *testing.T) { + home := t.TempDir() + st, calls := &fakeState{}, &[][]string{} + k := newKeeper(t, home, st, calls) + k.Account, k.Kind = "mesh-build-go", KindBuilder + if err := k.Once(context.Background()); err != nil { + t.Fatal(err) + } + if len(*calls) != 1 || !strings.Contains(strings.Join((*calls)[0], " "), filepath.Join(home, ".ssh", "mesh_ed25519")) { + t.Errorf("ssh-keygen calls = %v, want one for mesh_ed25519 in the home", *calls) + } + var e KeyEntry + if err := json.Unmarshal(st.puts["g14.mesh-build-go"], &e); err != nil { + t.Fatalf("entry under g14.mesh-build-go: %v (%v)", err, st.puts) + } + pub, _ := os.ReadFile(filepath.Join(home, ".ssh", "mesh_ed25519.pub")) + pk, _ := ParseKeyLine(string(pub)) + if e.Kind != "builder" || e.Public != strings.TrimSpace(string(pub)) || e.Fingerprint != pk.Fingerprint || e.Fingerprint == "" { + t.Errorf("entry = %+v", e) + } +} + +// 1469/AC2: an existing key, Once twice: ssh-keygen never, one put. +func TestExistingKeyIsPutOnceAcrossTwoRounds(t *testing.T) { + home := t.TempDir() + os.MkdirAll(filepath.Join(home, ".ssh"), 0o700) + os.WriteFile(filepath.Join(home, ".ssh", "mesh_ed25519"), []byte("mine"), 0o600) + os.WriteFile(filepath.Join(home, ".ssh", "mesh_ed25519.pub"), []byte(ed25519Line(9, "x")+"\n"), 0o644) + st, calls := &countState{}, &[][]string{} + k := newKeeper(t, home, &fakeState{}, calls) + k.State = st + for i := 0; i < 2; i++ { + if err := k.Once(context.Background()); err != nil { + t.Fatal(err) + } + } + if len(*calls) != 0 || st.n != 1 { + t.Errorf("ssh-keygen calls = %d, puts = %d, want 0 and 1", len(*calls), st.n) + } +} + +type countState struct{ n int } + +func (c *countState) Put(string, any) (uint64, error) { c.n++; return uint64(c.n), nil } + +// 1469/AC3: the entry's JSON is ssh-client's own (mesh-catalog modules/ssh-client keykeeper_test.go's keys +// entry: node, account, kind, public, fingerprint, at, in that order); mesh-catalog's gitea provider reads it. +func TestKeyEntryJSONIsSSHClients(t *testing.T) { + raw, err := json.Marshal(KeyEntry{Node: "g14", Account: "nox", Kind: "machine", Public: "ssh-ed25519 AAAA nox@g14 (mesh)", + Fingerprint: "SHA256:abc", At: "2026-10-16T12:00:00Z"}) + if err != nil { + t.Fatal(err) + } + want := `{"node":"g14","account":"nox","kind":"machine","public":"ssh-ed25519 AAAA nox@g14 (mesh)","fingerprint":"SHA256:abc","at":"2026-10-16T12:00:00Z"}` + if string(raw) != want { + t.Errorf("entry JSON = %s\nwant %s", raw, want) + } +} + +type failState struct{} + +func (failState) Put(string, any) (uint64, error) { return 0, errors.New("no grant yet") } + +// 1469/AC4: a failing put is said, and the next look is after KeyRetry, not KeyRound. +func TestKeepSaysAFailedPutAndRetriesSoon(t *testing.T) { + home := t.TempDir() + k := newKeeper(t, home, &fakeState{}, &[][]string{}) + k.State = failState{} + ctx, cancel := context.WithCancel(context.Background()) + var said []string + var pauses []time.Duration + k.After = func(d time.Duration) <-chan time.Time { + pauses = append(pauses, d) + cancel() + return make(chan time.Time) + } + k.Keep(ctx, func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }) + if len(said) != 1 || !strings.Contains(said[0], "no grant yet") { + t.Errorf("said = %v", said) + } + if len(pauses) != 1 || pauses[0] != KeyRetry { + t.Errorf("pauses = %v, want [%v]", pauses, KeyRetry) + } +}