diff --git a/cmd/objgitd/ssh.go b/cmd/objgitd/ssh.go new file mode 100644 index 0000000..71dc50b --- /dev/null +++ b/cmd/objgitd/ssh.go @@ -0,0 +1,76 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + + "github.com/go-git/go-billy/v6" + gossh "golang.org/x/crypto/ssh" +) + +const hostKeyPath = ".objgit/ssh_host_ed25519_key" + +// loadOrCreateHostKey loads the server's ed25519 host key from fs, generating +// and persisting one on first use so the key survives restarts. +func loadOrCreateHostKey(fs billy.Filesystem) (gossh.Signer, error) { + f, err := fs.Open(hostKeyPath) + if err == nil { + defer f.Close() + pemBytes, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("reading ssh host key: %w", err) + } + signer, err := gossh.ParsePrivateKey(pemBytes) + if err != nil { + return nil, fmt.Errorf("parsing ssh host key: %w", err) + } + return signer, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("opening ssh host key: %w", err) + } + + // Generate a new ed25519 key. + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating ssh host key: %w", err) + } + + block, err := gossh.MarshalPrivateKey(priv, "") + if err != nil { + return nil, fmt.Errorf("marshaling ssh host key: %w", err) + } + pemBytes := pem.EncodeToMemory(block) + + // Persist the key. MkdirAll first in case the parent dir doesn't exist. + if err := fs.MkdirAll(filepath.Dir(hostKeyPath), 0o700); err != nil && !errors.Is(err, os.ErrExist) { + return nil, fmt.Errorf("creating ssh host key directory: %w", err) + } + + wf, err := fs.Create(hostKeyPath) + if err != nil { + return nil, fmt.Errorf("creating ssh host key file: %w", err) + } + if _, err := wf.Write(pemBytes); err != nil { + wf.Close() + return nil, fmt.Errorf("writing ssh host key: %w", err) + } + if err := wf.Close(); err != nil { + return nil, fmt.Errorf("closing ssh host key file: %w", err) + } + + signer, err := gossh.ParsePrivateKey(pemBytes) + if err != nil { + return nil, fmt.Errorf("parsing generated ssh host key: %w", err) + } + + slog.Info("created ssh host key", "path", hostKeyPath) + return signer, nil +} diff --git a/cmd/objgitd/ssh_test.go b/cmd/objgitd/ssh_test.go new file mode 100644 index 0000000..e9dfb42 --- /dev/null +++ b/cmd/objgitd/ssh_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "bytes" + "io" + "testing" + + "github.com/go-git/go-billy/v6/memfs" +) + +func TestLoadOrCreateHostKey(t *testing.T) { + fs := memfs.New() + + s1, err := loadOrCreateHostKey(fs) + if err != nil { + t.Fatalf("first call: %v", err) + } + + // The key must have been persisted. + f, err := fs.Open(hostKeyPath) + if err != nil { + t.Fatalf("host key not persisted at %s: %v", hostKeyPath, err) + } + first, err := io.ReadAll(f) + f.Close() + if err != nil { + t.Fatal(err) + } + + // A second call must reuse the same key, not regenerate. + s2, err := loadOrCreateHostKey(fs) + if err != nil { + t.Fatalf("second call: %v", err) + } + if !bytes.Equal(s1.PublicKey().Marshal(), s2.PublicKey().Marshal()) { + t.Error("second call returned a different key; expected the persisted one to be reused") + } + + f2, err := fs.Open(hostKeyPath) + if err != nil { + t.Fatal(err) + } + second, err := io.ReadAll(f2) + f2.Close() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(first, second) { + t.Error("host key file changed on the second call; it must not be rewritten") + } +}