Remove ssh-client's own key keeper, key parser and runner, now in the SDK's forgekey (issue 1480), part 1 of 17

This commit is contained in:
2026-10-20 20:43:51 +00:00
parent c294f7bd16
commit 1e1def4d32
4 changed files with 0 additions and 426 deletions
@@ -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 <node>.<account>,
// 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):
}
}
}
@@ -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")
}
}
@@ -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
}
@@ -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 ""
}