diff --git a/modules/ssh-client/cmd/ssh-client-tools/keykeeper.go b/modules/ssh-client/cmd/ssh-client-tools/keykeeper.go deleted file mode 100644 index be736d96..00000000 --- a/modules/ssh-client/cmd/ssh-client-tools/keykeeper.go +++ /dev/null @@ -1,115 +0,0 @@ -package main - -// The key this machine's account uses to reach the forge over SSH (novox/hq ADR 0399, ADR 0400; issue 955, -// area S1). It is made here, on the machine, once: ssh-keygen writes ~/.ssh/mesh_ed25519 only when it is -// missing, so the private half never leaves the machine and an existing key is never replaced. The public -// half and its fingerprint go into this module's `keys` state, one entry per account under ., -// for the forge's provider to register. The tools bundle is the one process of this module, so the keeper -// runs inside it beside the tools (main.go), the way claude-code reports what it holds. - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - "time" -) - -// KeyFile is the key's name under ~/.ssh: never one of the operator's own id_* files. -const KeyFile = "mesh_ed25519" - -// KeyKind is the kind of key this process makes: the machine's account's own (ADR 0400 §1). -const KeyKind = "machine" - -// KeyRetry is how soon it looks again after a failure. -const KeyRetry = 30 * time.Second - -// KeyRound is how often the keeper looks again. -const KeyRound = 10 * time.Minute - -// KeyEntry is one value of the keys state. It carries the public half only. -type KeyEntry struct { - Node string `json:"node"` - Account string `json:"account"` - Kind string `json:"kind"` - Public string `json:"public"` - Fingerprint string `json:"fingerprint"` - At string `json:"at"` -} - -// KeyPutter is the keys state, as far as the keeper needs it. -type KeyPutter interface { - Put(key string, value any) error -} - -// KeyKeeper keeps one account's key and its entry. -type KeyKeeper struct { - Home, Node, Account string - Run Runner - State KeyPutter - Now func() time.Time - - reported string // the public half and fingerprint last put -} - -func (k *KeyKeeper) path() string { return filepath.Join(k.Home, ".ssh", KeyFile) } - -// Once makes the key when it is missing and puts the entry when it differs from the one last put. -func (k *KeyKeeper) Once(ctx context.Context) error { - if err := k.ensure(ctx); err != nil { - return err - } - raw, err := os.ReadFile(k.path() + ".pub") - if err != nil { - return fmt.Errorf("the public half: %w", err) - } - pk, err := ParseKeyLine(string(raw)) - if err != nil { - return fmt.Errorf("%s.pub: %w", k.path(), err) - } - public := strings.TrimSpace(string(raw)) - if sig := public + "|" + pk.Fingerprint; sig == k.reported { - return nil - } - entry := KeyEntry{Node: k.Node, Account: k.Account, Kind: KeyKind, Public: public, - Fingerprint: pk.Fingerprint, At: k.Now().UTC().Format(time.RFC3339)} - if err := k.State.Put(k.Node+"."+k.Account, entry); err != nil { - return err - } - k.reported = public + "|" + pk.Fingerprint - return nil -} - -// ensure makes the pair when the private half is missing. Never over an existing key. -func (k *KeyKeeper) ensure(ctx context.Context) error { - if _, err := os.Lstat(k.path()); err == nil { - return nil - } else if !os.IsNotExist(err) { - return err - } - if err := os.MkdirAll(filepath.Dir(k.path()), 0o700); err != nil { - return err - } - r := k.Run(ctx, nil, "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", k.Account+"@"+k.Node+" (mesh)", "-f", k.path()) - if r.Status != 0 || r.Err != "" { - return fmt.Errorf("ssh-keygen: %s", firstLine(r.Err+" "+r.Stderr)) - } - return nil -} - -// Keep runs Once now and every KeyRound, reporting a failure through say and trying again at the next round. -func (k *KeyKeeper) Keep(ctx context.Context, say func(string, ...any)) { - for { - pause := KeyRound - if err := k.Once(ctx); err != nil { - say("the forge key: %v", err) - pause = KeyRetry // the state or the bus's grant may arrive after the process - } - select { - case <-ctx.Done(): - return - case <-time.After(pause): - } - } -} diff --git a/modules/ssh-client/cmd/ssh-client-tools/keykeeper_test.go b/modules/ssh-client/cmd/ssh-client-tools/keykeeper_test.go deleted file mode 100644 index 0ceb52e1..00000000 --- a/modules/ssh-client/cmd/ssh-client-tools/keykeeper_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package main - -import ( - "context" - "encoding/base64" - "encoding/binary" - "encoding/json" - "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) error { - raw, _ := json.Marshal(value) - if f.puts == nil { - f.puts = map[string][]byte{} - } - f.puts[key] = raw - return 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", 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") - } -} diff --git a/modules/ssh-client/cmd/ssh-client-tools/keys.go b/modules/ssh-client/cmd/ssh-client-tools/keys.go deleted file mode 100644 index b248badd..00000000 --- a/modules/ssh-client/cmd/ssh-client-tools/keys.go +++ /dev/null @@ -1,110 +0,0 @@ -package main - -// Keys, by their public half only. A fingerprint is computed here from the public key's bytes, as -// ssh-keygen -l does (SHA256 of the key blob, unpadded base64); a private key is never read by this -// process — its first line, the PEM header, says it is one, and ssh-keygen is asked about it. - -import ( - "crypto/sha256" - "encoding/base64" - "encoding/binary" - "fmt" - "math/big" - "strings" -) - -// KeyTypes are the public key algorithms an authorized_keys or known_hosts line can carry. -var KeyTypes = map[string]bool{ - "ssh-ed25519": true, "ssh-rsa": true, "ssh-dss": true, - "ecdsa-sha2-nistp256": true, "ecdsa-sha2-nistp384": true, "ecdsa-sha2-nistp521": true, - "sk-ssh-ed25519@openssh.com": true, "sk-ecdsa-sha2-nistp256@openssh.com": true, -} - -// PublicKey is one public key as a line carries it. -type PublicKey struct { - Type string `json:"type"` - Bits int `json:"bits"` - Fingerprint string `json:"fingerprint"` - Comment string `json:"comment"` - Options string `json:"options,omitempty"` - Weak string `json:"weak,omitempty"` -} - -// ParseKeyLine reads one authorized_keys line (options, type, key, comment) or a public key file's. -func ParseKeyLine(line string) (PublicKey, error) { - fields := fieldsQuoted(strings.TrimSpace(line)) - for i, f := range fields { - if !KeyTypes[f] || i+1 >= len(fields) { - continue - } - k := PublicKey{Type: f, Options: strings.Join(fields[:i], " "), Comment: strings.Join(fields[i+2:], " ")} - blob, err := base64.StdEncoding.DecodeString(fields[i+1]) - if err != nil { - return k, fmt.Errorf("the key after %s is not base64", f) - } - sum := sha256.Sum256(blob) - k.Fingerprint = "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:]) - k.Bits = bitsOf(f, blob) - switch { - case f == "ssh-dss": - k.Weak = "DSA: refused by current OpenSSH" - case f == "ssh-rsa" && k.Bits > 0 && k.Bits < 3072: - k.Weak = fmt.Sprintf("RSA of %d bits: below 3072", k.Bits) - } - return k, nil - } - return PublicKey{}, fmt.Errorf("no public key on this line") -} - -// fieldsQuoted splits on spaces outside double quotes, as sshd reads an options field. -func fieldsQuoted(s string) []string { - var out []string - var cur strings.Builder - quoted := false - for _, ch := range s { - switch { - case ch == '"': - quoted = !quoted - cur.WriteRune(ch) - case (ch == ' ' || ch == '\t') && !quoted: - if cur.Len() > 0 { - out = append(out, cur.String()) - cur.Reset() - } - default: - cur.WriteRune(ch) - } - } - if cur.Len() > 0 { - out = append(out, cur.String()) - } - return out -} - -// bitsOf reads a key's size from its blob: the RSA modulus, the curve, or ed25519's fixed 256. -func bitsOf(kind string, blob []byte) int { - strs := [][]byte{} - for len(blob) >= 4 { - n := binary.BigEndian.Uint32(blob) - if int(n) > len(blob)-4 { - break - } - strs = append(strs, blob[4:4+n]) - blob = blob[4+n:] - } - switch { - case strings.Contains(kind, "ed25519"): - return 256 - case kind == "ssh-rsa" && len(strs) >= 3: - return new(big.Int).SetBytes(strs[2]).BitLen() - case kind == "ssh-dss" && len(strs) >= 2: - return new(big.Int).SetBytes(strs[1]).BitLen() - case strings.Contains(kind, "nistp256"): - return 256 - case strings.Contains(kind, "nistp384"): - return 384 - case strings.Contains(kind, "nistp521"): - return 521 - } - return 0 -} diff --git a/modules/ssh-client/cmd/ssh-client-tools/runner.go b/modules/ssh-client/cmd/ssh-client-tools/runner.go deleted file mode 100644 index 35ae5798..00000000 --- a/modules/ssh-client/cmd/ssh-client-tools/runner.go +++ /dev/null @@ -1,61 +0,0 @@ -package main - -import ( - "bytes" - "context" - "errors" - "fmt" - "os" - "os/exec" - "strings" - "time" -) - -// Ran is what a command did: its output, its exit status, and why it never ran to an answer. -type Ran struct { - Stdout string - Stderr string - Status int - // Err is "ENOENT" when the program is not installed, or says it was ended for taking too long. - Err string -} - -// Runner runs one command with extra environment words, so every tool can be tested without ssh. -type Runner func(ctx context.Context, env []string, name string, args ...string) Ran - -// CallTimeout bounds one command: below the runtime's thirty-second call limit. -const CallTimeout = 20 * time.Second - -// ExecRunner runs a command on this machine, with no terminal and nothing on its stdin, bounded by -// CallTimeout. -func ExecRunner(ctx context.Context, env []string, name string, args ...string) Ran { - ctx, cancel := context.WithTimeout(ctx, CallTimeout) - defer cancel() - cmd := exec.CommandContext(ctx, name, args...) - cmd.Env = append(os.Environ(), env...) - var out, errb bytes.Buffer - cmd.Stdout, cmd.Stderr = &out, &errb - err := cmd.Run() - r := Ran{Stdout: out.String(), Stderr: errb.String()} - var exitErr *exec.ExitError - switch { - case errors.Is(ctx.Err(), context.DeadlineExceeded): - r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout/time.Second)) - case errors.Is(err, exec.ErrNotFound): - r.Status, r.Err = 127, "ENOENT" - case errors.As(err, &exitErr): - r.Status = exitErr.ExitCode() - case err != nil: - r.Status, r.Err = 1, err.Error() - } - return r -} - -func firstLine(s string) string { - for _, l := range strings.Split(s, "\n") { - if l = strings.TrimSpace(l); l != "" { - return l - } - } - return "" -}