gotextlog

gotextlog
git clone https://git.ryansepassi.com/git/gotextlog.git
Log | Files | Refs | README

source.go (4311B)


      1 package sshkey
      2 
      3 import (
      4 	"bytes"
      5 	"context"
      6 	"crypto/rand"
      7 	"errors"
      8 	"fmt"
      9 	"net"
     10 	"os"
     11 	"path/filepath"
     12 	"sort"
     13 	"strings"
     14 
     15 	"golang.org/x/crypto/ssh"
     16 	"golang.org/x/crypto/ssh/agent"
     17 )
     18 
     19 // Key identifies a local public key. PublicKey is the canonical OpenSSH
     20 // authorized-key representation without a comment.
     21 type Key struct {
     22 	Path        string
     23 	PublicKey   string
     24 	Fingerprint string
     25 }
     26 
     27 // Source separates TUI authentication from local key storage and signing.
     28 type Source interface {
     29 	Keys(context.Context) ([]Key, error)
     30 	Sign(context.Context, Key, []byte) ([]byte, error)
     31 }
     32 
     33 type LocalSource struct {
     34 	sshDir      string
     35 	agentSocket string
     36 }
     37 
     38 func NewLocalSource(sshDir, agentSocket string) *LocalSource {
     39 	return &LocalSource{sshDir: sshDir, agentSocket: agentSocket}
     40 }
     41 
     42 func NewDefaultSource() (*LocalSource, error) {
     43 	home, err := os.UserHomeDir()
     44 	if err != nil {
     45 		return nil, fmt.Errorf("find home directory: %w", err)
     46 	}
     47 	return NewLocalSource(filepath.Join(home, ".ssh"), os.Getenv("SSH_AUTH_SOCK")), nil
     48 }
     49 
     50 func (s *LocalSource) Keys(ctx context.Context) ([]Key, error) {
     51 	if err := ctx.Err(); err != nil {
     52 		return nil, err
     53 	}
     54 	paths, err := filepath.Glob(filepath.Join(s.sshDir, "*.pub"))
     55 	if err != nil {
     56 		return nil, fmt.Errorf("find SSH public keys: %w", err)
     57 	}
     58 	sort.Strings(paths)
     59 	keys := make([]Key, 0, len(paths))
     60 	seen := make(map[string]bool)
     61 	for _, path := range paths {
     62 		if err := ctx.Err(); err != nil {
     63 			return nil, err
     64 		}
     65 		contents, readErr := os.ReadFile(path)
     66 		if readErr != nil {
     67 			return nil, fmt.Errorf("read SSH public key %s: %w", path, readErr)
     68 		}
     69 		publicKey, _, _, _, parseErr := ssh.ParseAuthorizedKey(contents)
     70 		if parseErr != nil || publicKey.Type() != ssh.KeyAlgoED25519 {
     71 			continue
     72 		}
     73 		fingerprint := ssh.FingerprintSHA256(publicKey)
     74 		if seen[fingerprint] {
     75 			continue
     76 		}
     77 		seen[fingerprint] = true
     78 		keys = append(keys, Key{
     79 			Path:        path,
     80 			PublicKey:   strings.TrimSpace(string(ssh.MarshalAuthorizedKey(publicKey))),
     81 			Fingerprint: fingerprint,
     82 		})
     83 	}
     84 	return keys, nil
     85 }
     86 
     87 func (s *LocalSource) Sign(ctx context.Context, key Key, message []byte) ([]byte, error) {
     88 	if err := ctx.Err(); err != nil {
     89 		return nil, err
     90 	}
     91 	publicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.PublicKey))
     92 	if err != nil {
     93 		return nil, fmt.Errorf("parse selected SSH public key: %w", err)
     94 	}
     95 	if publicKey.Type() != ssh.KeyAlgoED25519 {
     96 		return nil, errors.New("selected SSH key is not Ed25519")
     97 	}
     98 
     99 	if s.agentSocket != "" {
    100 		signature, found, agentErr := s.signWithAgent(ctx, publicKey, message)
    101 		if agentErr == nil && found {
    102 			return ssh.Marshal(signature), nil
    103 		}
    104 		if err := ctx.Err(); err != nil {
    105 			return nil, err
    106 		}
    107 	}
    108 
    109 	privatePath := strings.TrimSuffix(key.Path, ".pub")
    110 	privateBytes, err := os.ReadFile(privatePath)
    111 	if err != nil {
    112 		return nil, fmt.Errorf("selected key is not loaded in ssh-agent and read private key %s: %w", privatePath, err)
    113 	}
    114 	signer, err := ssh.ParsePrivateKey(privateBytes)
    115 	if err != nil {
    116 		var passphraseErr *ssh.PassphraseMissingError
    117 		if errors.As(err, &passphraseErr) {
    118 			return nil, errors.New("selected SSH key is encrypted; load it into ssh-agent")
    119 		}
    120 		return nil, fmt.Errorf("parse private key %s: %w", privatePath, err)
    121 	}
    122 	if !bytes.Equal(signer.PublicKey().Marshal(), publicKey.Marshal()) {
    123 		return nil, fmt.Errorf("private key %s does not match selected public key", privatePath)
    124 	}
    125 	signature, err := signer.Sign(rand.Reader, message)
    126 	if err != nil {
    127 		return nil, fmt.Errorf("sign authentication challenge: %w", err)
    128 	}
    129 	return ssh.Marshal(signature), nil
    130 }
    131 
    132 func (s *LocalSource) signWithAgent(ctx context.Context, publicKey ssh.PublicKey, message []byte) (*ssh.Signature, bool, error) {
    133 	connection, err := (&net.Dialer{}).DialContext(ctx, "unix", s.agentSocket)
    134 	if err != nil {
    135 		return nil, false, err
    136 	}
    137 	defer connection.Close()
    138 
    139 	signers, err := agent.NewClient(connection).Signers()
    140 	if err != nil {
    141 		return nil, false, err
    142 	}
    143 	for _, signer := range signers {
    144 		if !bytes.Equal(signer.PublicKey().Marshal(), publicKey.Marshal()) {
    145 			continue
    146 		}
    147 		signature, signErr := signer.Sign(rand.Reader, message)
    148 		if signErr != nil {
    149 			return nil, true, signErr
    150 		}
    151 		return signature, true, nil
    152 	}
    153 	return nil, false, nil
    154 }
    155 
    156 var _ Source = (*LocalSource)(nil)