From c5705ed5517d59b594ca393353893887d3b22406 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 20:03:17 +0000 Subject: [PATCH] Move the key keeper into mesh-sdk and add the build modules' shared steps (issues 1469, 1470, 1471, 1473, 1474, 1479), part 1 of 4 --- go/forgekey/keykeeper.go | 127 +++++++++++++++++++++++++++++++++++++++ go/forgekey/keys.go | 110 +++++++++++++++++++++++++++++++++ go/forgekey/runner.go | 61 +++++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 go/forgekey/keykeeper.go create mode 100644 go/forgekey/keys.go create mode 100644 go/forgekey/runner.go diff --git a/go/forgekey/keykeeper.go b/go/forgekey/keykeeper.go new file mode 100644 index 0000000..cb75668 --- /dev/null +++ b/go/forgekey/keykeeper.go @@ -0,0 +1,127 @@ +// Package forgekey is the key keeper every module that reaches the forge over SSH shares: ssh-client for a +// machine's accounts, a build module for its own (design 47 R4c-3, ADR 0466 §2; issue 1469). +package forgekey + +// 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" + +// The kinds of key (ADR 0400 §1, ADR 0466 §2): a machine's account's own, and a build module's. +const ( + KindMachine = "machine" + KindBuilder = "builder" +) + +// 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) (uint64, error) +} + +// KeyKeeper keeps one account's key and its entry. +type KeyKeeper struct { + Home, Node, Account string + Kind string // KindMachine or KindBuilder + Run Runner + State KeyPutter + Now func() time.Time + // After waits between rounds; time.After when nil. A test sets it to see which pause was chosen. + After func(time.Duration) <-chan 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: k.Kind, 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 + } + after := k.After + if after == nil { + after = time.After + } + select { + case <-ctx.Done(): + return + case <-after(pause): + } + } +} diff --git a/go/forgekey/keys.go b/go/forgekey/keys.go new file mode 100644 index 0000000..4af0499 --- /dev/null +++ b/go/forgekey/keys.go @@ -0,0 +1,110 @@ +package forgekey + +// 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/go/forgekey/runner.go b/go/forgekey/runner.go new file mode 100644 index 0000000..9cc08e1 --- /dev/null +++ b/go/forgekey/runner.go @@ -0,0 +1,61 @@ +package forgekey + +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 "" +}