The ssh install and ssh to commands (closes #2)
All checks were successful
check / check (push) Successful in 2m30s

keyfunc ssh install appends the public line on a host through the system ssh, only when absent, feeding the line on standard input; keyfunc ssh to serves the derived key from an in-process agent on a private socket and runs the system ssh with it, the private key never on disk. Two review rounds; the second passed with no findings.

Model: opus-5 (implementation and review); fable-5-1 (landing)
This commit was merged in pull request #8.
This commit is contained in:
2026-09-07 18:49:42 +02:00
parent 5bbeec86d6
commit b9c8631788
7 changed files with 613 additions and 12 deletions

86
internal/sshkey/agent.go Normal file
View File

@@ -0,0 +1,86 @@
package sshkey
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"golang.org/x/crypto/ssh/agent"
)
// Agent is an SSH agent that holds one key and serves it on a unix
// socket. The socket sits in a directory of its own that only its
// owner may enter, and the key stays in memory: nothing is written to
// disk.
type Agent struct {
socket string
listener net.Listener
}
// Serve starts an agent holding this key under the given comment.
// Stop takes it down again.
func (k *Key) Serve(ctx context.Context, comment string) (*Agent, error) {
keyring := agent.NewKeyring()
err := keyring.Add(agent.AddedKey{
PrivateKey: k.private,
Comment: comment,
})
if err != nil {
return nil, fmt.Errorf("giving the key to the agent: %w", err)
}
// A temporary directory is made enterable by its owner alone,
// which is the protection the socket inside it has.
directory, err := os.MkdirTemp("", "keyfunc-agent-")
if err != nil {
return nil, fmt.Errorf("making the agent directory: %w", err)
}
socket := filepath.Join(directory, "socket")
var listen net.ListenConfig
listener, err := listen.Listen(ctx, "unix", socket)
if err != nil {
_ = os.RemoveAll(directory)
return nil, fmt.Errorf("listening on the agent socket: %w", err)
}
served := &Agent{socket: socket, listener: listener}
go served.accept(keyring)
return served, nil
}
// Socket is the path to point ssh at.
func (a *Agent) Socket() string {
return a.socket
}
// Stop takes the agent down and removes the socket and the directory
// it is in.
func (a *Agent) Stop() {
_ = a.listener.Close()
_ = os.RemoveAll(filepath.Dir(a.socket))
}
// accept answers connections until Stop closes the listener.
func (a *Agent) accept(keyring agent.Agent) {
for {
connection, err := a.listener.Accept()
if err != nil {
return
}
go func() {
defer func() { _ = connection.Close() }()
_ = agent.ServeAgent(keyring, connection)
}()
}
}

View File

@@ -1,6 +1,9 @@
package sshkey_test
import (
"net"
"os"
"path/filepath"
"strings"
"testing"
@@ -8,8 +11,16 @@ import (
"git.eeqj.de/sneak/keyfunc/internal/sshkey"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)
// agentDirectoryMode is what the directory holding the agent socket
// has to be: nobody but its owner may enter it.
const agentDirectoryMode = 0o700
// exampleIndex is the key index every test here derives at.
const exampleIndex = 0
// example returns the mnemonic every BIP-39 document uses to show its
// test vectors: eleven abandons and about.
func example() string {
@@ -26,7 +37,7 @@ func TestTooFewBytesAreRefused(t *testing.T) {
func TestTheCommentIsPutAtTheEndOfTheLine(t *testing.T) {
t.Parallel()
key := forIndex(t, 0)
key := exampleKey(t)
line, err := key.Line("hello")
require.NoError(t, err)
@@ -37,7 +48,7 @@ func TestTheCommentIsPutAtTheEndOfTheLine(t *testing.T) {
func TestThePrivateKeyCarriesTheSamePublicKey(t *testing.T) {
t.Parallel()
key := forIndex(t, 0)
key := exampleKey(t)
line, err := key.Line("")
require.NoError(t, err)
@@ -54,11 +65,60 @@ func TestThePrivateKeyCarriesTheSamePublicKey(t *testing.T) {
require.Equal(t, line, back)
}
// forIndex derives the key for one index.
func forIndex(t *testing.T, index uint32) *sshkey.Key {
func TestTheAgentServesTheOneKeyAndNothingElse(t *testing.T) {
t.Parallel()
key := exampleKey(t)
served, err := key.Serve(t.Context(), "a comment")
require.NoError(t, err)
t.Cleanup(served.Stop)
directory, err := os.Stat(filepath.Dir(served.Socket()))
require.NoError(t, err)
require.Equal(t,
os.FileMode(agentDirectoryMode), directory.Mode().Perm(),
)
var dialer net.Dialer
connection, err := dialer.DialContext(t.Context(), "unix", served.Socket())
require.NoError(t, err)
defer func() { _ = connection.Close() }()
held, err := agent.NewClient(connection).List()
require.NoError(t, err)
require.Len(t, held, 1)
line, err := key.Line("a comment")
require.NoError(t, err)
require.Equal(t, line, held[0].String())
}
func TestStoppingTheAgentLeavesNothingBehind(t *testing.T) {
t.Parallel()
served, err := exampleKey(t).Serve(t.Context(), "a comment")
require.NoError(t, err)
directory := filepath.Dir(served.Socket())
require.DirExists(t, directory)
served.Stop()
require.NoDirExists(t, directory)
var dialer net.Dialer
_, err = dialer.DialContext(t.Context(), "unix", served.Socket())
require.Error(t, err)
}
// exampleKey derives the key the example mnemonic gives.
func exampleKey(t *testing.T) *sshkey.Key {
t.Helper()
material, err := derive.Bytes(example(), sshkey.Application, index)
material, err := derive.Bytes(example(), sshkey.Application, exampleIndex)
require.NoError(t, err)
key, err := sshkey.New(material)