Move ssh-client's tests and dependencies onto the SDK's forgekey (issue 1480), part 3 of 17

This commit is contained in:
2026-10-20 20:45:35 +00:00
parent bf8f47ce66
commit 8dc0bc98bd
7 changed files with 159 additions and 37 deletions
@@ -0,0 +1,21 @@
package main
import (
"encoding/base64"
"encoding/binary"
)
// 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
}
@@ -0,0 +1,82 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// Issue 1480: the bundle's keeper is go/forgekey's, kind machine; no copy of it is left in this module.
type recordState struct{ puts map[string][]byte }
func (r *recordState) Put(key string, value any) (uint64, error) {
raw, _ := json.Marshal(value)
if r.puts == nil {
r.puts = map[string][]byte{}
}
r.puts[key] = raw
return uint64(len(r.puts)), nil
}
// keygenStandIn writes the pair ssh-keygen -f names, as ssh-keygen would.
func keygenStandIn(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
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 forgekey.Ran{Status: 1, Stderr: "unexpected"}
}
os.WriteFile(file, []byte("PRIVATE\n"), 0o600)
os.WriteFile(file+".pub", []byte(ed25519Line(7, comment)+"\n"), 0o644)
return forgekey.Ran{}
}
// 1480/AC1
func TestTheBundlesKeeperIsForgekeysOfKindMachineWithItsEntryAtNodeAccount(t *testing.T) {
home, st := t.TempDir(), &recordState{}
k := keeperFor(home, "g14", "nox", keygenStandIn, st)
k.Now = func() time.Time { return time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) }
if err := k.Once(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(home, ".ssh", "mesh_ed25519")); err != nil {
t.Errorf("the key file mesh_ed25519 was not made in an empty home: %v", err)
}
raw, ok := st.puts["g14.nox"]
if !ok || len(st.puts) != 1 {
t.Fatalf("entries = %v, want one at g14.nox", st.puts)
}
var e forgekey.KeyEntry
if err := json.Unmarshal(raw, &e); err != nil {
t.Fatal(err)
}
if e.Kind != "machine" || e.Node != "g14" || e.Account != "nox" || !strings.HasSuffix(e.Public, " nox@g14 (mesh)") {
t.Errorf("entry = %s", raw)
}
}
// 1480/AC2
func TestNoCopyOfTheKeeperIsLeftInThisModule(t *testing.T) {
copyOf := regexp.MustCompile(`(?m)^(type (KeyKeeper|KeyEntry|Runner|Ran)\b|func ParseKeyLine\b|func ExecRunner\b)`)
files, _ := filepath.Glob("*.go")
for _, f := range files {
raw, _ := os.ReadFile(f)
if m := copyOf.Find(raw); m != nil {
t.Errorf("%s declares %q, which go/forgekey holds", f, m)
}
}
}
@@ -15,6 +15,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/forgekey"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -170,7 +171,7 @@ func TestTheKeyProcessMakesTheKeyAndPutsItsEntryOverTheBus(t *testing.T) {
if raw == nil {
t.Fatalf("no entry %s in the keys state within 60 s", key)
}
var e KeyEntry
var e forgekey.KeyEntry
if err := json.Unmarshal(raw, &e); err != nil {
t.Fatal(err)
}
@@ -180,7 +181,7 @@ func TestTheKeyProcessMakesTheKeyAndPutsItsEntryOverTheBus(t *testing.T) {
if strings.Contains(strings.ToUpper(string(raw)), "PRIVATE") {
t.Errorf("the entry carries private material: %s", raw)
}
info, err := os.Stat(filepath.Join(home, ".ssh", KeyFile))
info, err := os.Stat(filepath.Join(home, ".ssh", forgekey.KeyFile))
if err != nil {
t.Fatal(err)
}
@@ -12,6 +12,8 @@ import (
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// Public keys made for these tests only; their private halves were never kept.
@@ -29,7 +31,7 @@ type call struct {
}
type fake struct {
answer func(c call) Ran
answer func(c call) forgekey.Ran
mu sync.Mutex
calls []call
}
@@ -41,13 +43,13 @@ func (f *fake) recorded() []call {
return append([]call(nil), f.calls...)
}
func (f *fake) run(_ context.Context, env []string, name string, args ...string) Ran {
func (f *fake) run(_ context.Context, env []string, name string, args ...string) forgekey.Ran {
c := call{env, name, args}
f.mu.Lock()
f.calls = append(f.calls, c)
f.mu.Unlock()
if f.answer == nil {
return Ran{Status: 1}
return forgekey.Ran{Status: 1}
}
return f.answer(c)
}
@@ -130,19 +132,19 @@ func TestKeysAreDescribedByTheirPublicHalfAndAPassphraseIsAskedNotRead(t *testin
files := map[string]string{"config": layout["config"], "id_ed25519": "-----BEGIN OPENSSH PRIVATE KEY-----\nnot a key\n",
"id_ed25519.pub": edPub + "\n", "id_box": "-----BEGIN OPENSSH PRIVATE KEY-----\nnot a key\n", "id_box.pub": rsaPub + "\n",
"stray": "-----BEGIN RSA PRIVATE KEY-----\nnot a key\n", "notes.txt": "hello\n"}
f := &fake{answer: func(c call) Ran {
f := &fake{answer: func(c call) forgekey.Ran {
if c.name == "ssh-keygen" && c.args[0] == "-y" {
if strings.HasSuffix(c.args[len(c.args)-1], "id_box") {
return Ran{Status: 1, Stderr: "Load key \"id_box\": incorrect passphrase supplied to decrypt private key\n"}
return forgekey.Ran{Status: 1, Stderr: "Load key \"id_box\": incorrect passphrase supplied to decrypt private key\n"}
}
if strings.HasSuffix(c.args[len(c.args)-1], "id_ed25519") {
return Ran{Stdout: edPub + "\n"}
return forgekey.Ran{Stdout: edPub + "\n"}
}
}
if c.name == "ssh-keygen" && c.args[0] == "-l" {
return Ran{Stdout: "256 " + edFP + " no comment (ED25519)\n"}
return forgekey.Ran{Stdout: "256 " + edFP + " no comment (ED25519)\n"}
}
return Ran{Status: 1, Stderr: "unexpected"}
return forgekey.Ran{Status: 1, Stderr: "unexpected"}
}}
keys, err := client(home(t, files), f).Keys(context.Background())
if err != nil {
@@ -173,7 +175,7 @@ func TestKeysAreDescribedByTheirPublicHalfAndAPassphraseIsAskedNotRead(t *testin
func TestAPublicHalfThatIsAnotherKeysIsFound(t *testing.T) {
files := map[string]string{"config": layout["config"], "id_ed25519": "-----BEGIN OPENSSH PRIVATE KEY-----\n", "id_ed25519.pub": rsaPub + "\n"}
f := &fake{answer: func(c call) Ran { return Ran{Stdout: edPub + "\n"} }}
f := &fake{answer: func(c call) forgekey.Ran { return forgekey.Ran{Stdout: edPub + "\n"} }}
keys, _ := client(home(t, files), f).Keys(context.Background())
if len(keys) != 1 || keys[0].PublicMismatch == "" || keys[0].Fingerprint != edFP {
t.Fatalf("%+v", keys)
@@ -181,15 +183,15 @@ func TestAPublicHalfThatIsAnotherKeysIsFound(t *testing.T) {
}
func TestFingerprintsAreSshKeygens(t *testing.T) {
k, err := ParseKeyLine("from=\"10.0.0.0/8,192.0.2.1\",no-pty " + edPub)
k, err := forgekey.ParseKeyLine("from=\"10.0.0.0/8,192.0.2.1\",no-pty " + edPub)
if err != nil || k.Fingerprint != edFP || k.Bits != 256 || k.Comment != "op@laptop" || !strings.HasPrefix(k.Options, "from=") {
t.Fatalf("%+v %v", k, err)
}
k, _ = ParseKeyLine(rsaPub)
k, _ = forgekey.ParseKeyLine(rsaPub)
if k.Fingerprint != rsaFP || k.Bits != 2048 || !strings.Contains(k.Weak, "below 3072") {
t.Fatalf("%+v", k)
}
if _, err := ParseKeyLine("ssh-ed25519 !!!notbase64"); err == nil {
if _, err := forgekey.ParseKeyLine("ssh-ed25519 !!!notbase64"); err == nil {
t.Fatal("garbage accepted")
}
}
@@ -245,16 +247,16 @@ func scanOf(host, pub string) string { return host + " " + pub + "\n" }
func TestKnownHostComparesWhatIsKnownWithWhatIsOffered(t *testing.T) {
h := home(t, map[string]string{"config": layout["config"], "known_hosts": "ace.internal " + edPub + "\n"})
offered := edPub
f := &fake{answer: func(c call) Ran {
f := &fake{answer: func(c call) forgekey.Ran {
switch {
case c.name == "ssh-keygen" && c.args[0] == "-F":
return Ran{Stdout: "# Host ace.internal found: line 1\nace.internal " + edPub + "\n"}
return forgekey.Ran{Stdout: "# Host ace.internal found: line 1\nace.internal " + edPub + "\n"}
case c.name == "ssh-keyscan":
return Ran{Stdout: scanOf("ace.internal", offered)}
return forgekey.Ran{Stdout: scanOf("ace.internal", offered)}
case c.name == "ssh-keygen" && c.args[0] == "-R":
return Ran{}
return forgekey.Ran{}
}
return Ran{Status: 1}
return forgekey.Ran{Status: 1}
}}
c := client(h, f)
got, err := c.KnownHost(context.Background(), "ace.internal", 22, false)
@@ -293,11 +295,11 @@ func TestKnownHostComparesWhatIsKnownWithWhatIsOffered(t *testing.T) {
func TestAnUnreachableHostIsNotRefreshed(t *testing.T) {
h := home(t, map[string]string{"known_hosts": ""})
f := &fake{answer: func(c call) Ran {
f := &fake{answer: func(c call) forgekey.Ran {
if c.name == "ssh-keyscan" {
return Ran{Status: 1}
return forgekey.Ran{Status: 1}
}
return Ran{Status: 1}
return forgekey.Ran{Status: 1}
}}
got, err := client(h, f).KnownHost(context.Background(), "gone.example", 22, false)
if err != nil || !strings.HasPrefix(got["state"].(string), "unreachable") {
@@ -310,7 +312,7 @@ func TestAnUnreachableHostIsNotRefreshed(t *testing.T) {
func TestTestSaysHowItAuthenticatedOrWhyNot(t *testing.T) {
ok := "debug1: Connecting to ace.internal [10.0.0.2] port 22.\ndebug1: Server accepts key: /h/.ssh/id_ed25519 ED25519 " + edFP + "\nAuthenticated to ace.internal ([10.0.0.2]:22) using \"publickey\".\n"
f := &fake{answer: func(c call) Ran { return Ran{Stderr: ok} }}
f := &fake{answer: func(c call) forgekey.Ran { return forgekey.Ran{Stderr: ok} }}
got, err := client(t.TempDir(), f).Test(context.Background(), "ace")
if err != nil || got["ok"] != true || got["method"] != "publickey" || got["connected_to"] != "10.0.0.2:22" {
t.Fatalf("%v %v", got, err)
@@ -320,7 +322,7 @@ func TestTestSaysHowItAuthenticatedOrWhyNot(t *testing.T) {
t.Fatalf("not a batch test: %s", args)
}
denied := "debug1: Offering public key: /h/.ssh/id_box RSA " + rsaFP + "\nop@ace.internal: Permission denied (publickey).\n"
f = &fake{answer: func(c call) Ran { return Ran{Status: 255, Stderr: denied} }}
f = &fake{answer: func(c call) forgekey.Ran { return forgekey.Ran{Status: 255, Stderr: denied} }}
got, _ = client(t.TempDir(), f).Test(context.Background(), "ace")
if got["ok"] != false || got["why"] != "no key it offered was accepted" || !reflect.DeepEqual(got["offered"], []string{"/h/.ssh/id_box"}) {
t.Fatalf("%v", got)
@@ -337,14 +339,14 @@ func TestCheckFindsWhatIsWrongAndSaysWhatItDidNotCheck(t *testing.T) {
h := home(t, files)
os.Chmod(filepath.Join(h, ".ssh", "config"), 0o666)
os.Chmod(filepath.Join(h, ".ssh", "id_ed25519"), 0o644)
f := &fake{answer: func(c call) Ran {
f := &fake{answer: func(c call) forgekey.Ran {
switch {
case c.name == "ssh-keygen" && c.args[0] == "-y":
return Ran{Stdout: edPub}
return forgekey.Ran{Stdout: edPub}
case c.name == "ssh-keyscan":
return Ran{Stdout: scanOf("x", edPub)}
return forgekey.Ran{Stdout: scanOf("x", edPub)}
}
return Ran{Status: 1}
return forgekey.Ran{Status: 1}
}}
got, err := client(h, f).Check(context.Background(), true)
if err != nil {
@@ -381,7 +383,7 @@ func TestCheckFindsWhatIsWrongAndSaysWhatItDidNotCheck(t *testing.T) {
// as problems, and says when no sweep has run.
func TestCheckShowsAFailedSweep(t *testing.T) {
h := home(t, map[string]string{"config": layout["config"], "known_hosts": ""})
f := &fake{answer: func(call) Ran { return Ran{Status: 1} }}
f := &fake{answer: func(call) forgekey.Ran { return forgekey.Ran{Status: 1} }}
c := client(h, f)
got, _ := c.Check(context.Background(), false)
if !slices.ContainsFunc(got["not_checked"].([]string), func(s string) bool { return strings.Contains(s, "the sweep") }) {
@@ -12,6 +12,8 @@ import (
"syscall"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// ADR 0415, issue 1017: the sweep keeps ~/.ssh to what this module knows and moves the rest out.
@@ -73,7 +75,7 @@ func TestSweepKeepsWhatItKnows(t *testing.T) {
t.Fatal(err)
}
for _, rel := range []string{"config", "known_hosts", "authorized_keys", "config.d/00-mesh", "config.d/10-forge",
KeyFile, KeyFile + ".pub", "id_ed25519", "id_ed25519.pub", "work_key", "work_key.pub", "work_key-cert.pub", "agent_only.pub", "removed-drone-20261004/id_drone", "authorized_keys2", "id_token"} {
forgekey.KeyFile, forgekey.KeyFile + ".pub", "id_ed25519", "id_ed25519.pub", "work_key", "work_key.pub", "work_key-cert.pub", "agent_only.pub", "removed-drone-20261004/id_drone", "authorized_keys2", "id_token"} {
if !exists(filepath.Join(dir, rel)) {
t.Errorf("%s was moved; it is known", rel)
}
+7 -7
View File
@@ -1,22 +1,22 @@
module sshclient
go 1.25.0
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020203945-1572e50e0ef0
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.51.0
github.com/nats-io/nats.go v1.54.0
)
require (
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
+14
View File
@@ -1,11 +1,17 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020200715-9e296295c05e h1:uf2o2A6dBOrISDUVw6U7+fv8ylwwF+UfRrGWKIQMFOE=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020200715-9e296295c05e/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020203945-1572e50e0ef0 h1:mr9HyARwzeZT7FxFHv1Xc2z7QErvDZ/F/pBMIpjGSd0=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020203945-1572e50e0ef0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
@@ -14,14 +20,22 @@ github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnY
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI=
github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=