The ssh install and ssh to commands (closes #2)
All checks were successful
check / check (push) Successful in 2m30s
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:
86
internal/sshkey/agent.go
Normal file
86
internal/sshkey/agent.go
Normal 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)
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user