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)