forked from charmbracelet/wishlist
-
Notifications
You must be signed in to change notification settings - Fork 0
/
client_auth.go
390 lines (352 loc) · 11.2 KB
/
client_auth.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
package wishlist
import (
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"github.com/charmbracelet/keygen"
"github.com/charmbracelet/log"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wishlist/home"
gossh "golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/crypto/ssh/knownhosts"
"golang.org/x/term"
)
var errNoRemoteAgent = fmt.Errorf("no agent forwarded")
// remoteBestAuthMethod returns an auth method.
//
// it first tries to use ssh-agent, if that's not available, it creates and uses a new key pair.
func remoteBestAuthMethod(e *Endpoint, s ssh.Session, in io.Reader) ([]gossh.AuthMethod, agent.Agent, closers, error) {
var methods []gossh.AuthMethod
var agt agent.Agent
var closers closers
for _, m := range e.Authentications() {
switch m {
case authModePassword:
method, err := passwordAuth(e, in, s)
if err != nil {
return nil, nil, closers, err
}
methods = append(methods, method)
case authModeKeyboardInteractive:
methods = append(methods, keyboardInteractiveAuth(in, s))
case authModePublicKey:
method, a, cl, err := tryRemoteAuthAgent(s)
if err != nil || method != nil {
agt = a
methods = append(methods, method)
closers = append(closers, cl...)
}
newKey, err := tryNewKey()
if err != nil {
return nil, nil, nil, err
}
methods = append(methods, newKey)
}
}
return methods, agt, closers, nil
}
// localBestAuthMethod figures out which authentication method is the best for
// the given endpoint.
//
// preference order:
// - the IdentityFiles, if they were set in the endpoint
// - the local ssh agent, if available
// - common key filenames under ~/.ssh/
//
// If any of the methods fails, it returns an error.
// It'll return a nil list if none of the methods is available.
func localBestAuthMethod(agt agent.Agent, e *Endpoint, in io.Reader, out io.Writer) ([]gossh.AuthMethod, error) {
var methods []gossh.AuthMethod
for _, m := range e.Authentications() {
switch m {
case authModePassword:
method, err := passwordAuth(e, in, out)
if err != nil {
return nil, err
}
methods = append(methods, method)
case authModeKeyboardInteractive:
methods = append(methods, keyboardInteractiveAuth(in, out))
case authModePublicKey:
if len(e.IdentityFiles) > 0 {
ids, err := tryIdendityFiles(e)
if err != nil {
return methods, err
}
methods = append(methods, ids...)
}
if method := agentAuthMethod(agt); method != nil {
methods = append(methods, method)
}
keys, err := tryUserKeys()
if err != nil {
return nil, err
}
methods = append(methods, keys...)
}
}
return methods, nil
}
// agentAuthMethod setups an auth method for the given agent.
func agentAuthMethod(agt agent.Agent) gossh.AuthMethod {
if agt == nil {
return nil
}
signers, _ := agt.Signers()
for _, signer := range signers {
log.Info(
"offering public key via ssh agent",
"key.type", signer.PublicKey().Type(),
"key.fingerprint", gossh.FingerprintSHA256(signer.PublicKey()),
)
}
return gossh.PublicKeysCallback(agt.Signers)
}
// getLocalAgent checks if there's a local agent at $SSH_AUTH_SOCK and, if so,
// returns a connection to it through agent.Agent.
func getLocalAgent() (agent.Agent, closers, error) {
socket := os.Getenv("SSH_AUTH_SOCK")
if socket == "" {
return nil, nil, nil
}
if _, err := os.Stat(socket); errors.Is(err, os.ErrNotExist) {
return nil, nil, nil
}
conn, err := net.Dial("unix", socket)
if err != nil {
return nil, nil, fmt.Errorf("failed to connect to SSH_AUTH_SOCK: %w", err)
}
return agent.NewClient(conn), closers{conn.Close}, nil
}
func getRemoteAgent(s ssh.Session) (agent.Agent, closers, error) {
_, _ = s.SendRequest("auth-agent-req@openssh.com", true, nil)
if !ssh.AgentRequested(s) {
return nil, nil, errNoRemoteAgent
}
l, err := ssh.NewAgentListener()
if err != nil {
return nil, nil, err //nolint:wrapcheck
}
go ssh.ForwardAgentConnections(l, s)
conn, err := net.Dial(l.Addr().Network(), l.Addr().String())
if err != nil {
return nil, closers{l.Close}, err //nolint:wrapcheck
}
return agent.NewClient(conn), closers{l.Close, conn.Close}, nil
}
// tryRemoteAuthAgent will try to use an ssh-agent to authenticate.
func tryRemoteAuthAgent(s ssh.Session) (gossh.AuthMethod, agent.Agent, closers, error) {
agent, closers, err := getRemoteAgent(s)
if err != nil {
if errors.Is(err, errNoRemoteAgent) {
wish.Errorln(s, fmt.Errorf("wishlist: ssh agent not available"))
return nil, nil, closers, nil
}
return nil, nil, closers, err
}
signers, _ := agent.Signers()
for _, signer := range signers {
log.Info(
"offering public key via ssh agent",
"key.type", signer.PublicKey().Type(),
"key.fingerprint", gossh.FingerprintSHA256(signer.PublicKey()),
)
}
return gossh.PublicKeysCallback(agent.Signers), agent, closers, nil
}
// tryNewKey will create a .wishlist/client_ed25519 keypair if one does not exist.
// It will return an auth method that uses the keypair if it exist or is successfully created.
func tryNewKey() (gossh.AuthMethod, error) {
path, err := filepath.Abs(".wishlist/client_ed25519")
if err != nil {
return nil, fmt.Errorf("could not create client key: %w", err)
}
key, err := keygen.New(path, keygen.WithKeyType(keygen.Ed25519))
if err != nil {
return nil, fmt.Errorf("could not create new client key at %q: %w", path, err)
}
signer := key.Signer()
log.Info(
"offering public key",
"key.path", path,
"key.type", signer.PublicKey().Type(),
"key.fingerprint", gossh.FingerprintSHA256(signer.PublicKey()),
)
if !key.KeyPairExists() {
if err := key.WriteKeys(); err != nil {
return nil, fmt.Errorf("could not write key: %w", err)
}
}
return gossh.PublicKeys(signer), nil
}
func tryIdendityFiles(e *Endpoint) ([]gossh.AuthMethod, error) {
methods := make([]gossh.AuthMethod, 0, len(e.IdentityFiles))
for _, id := range e.IdentityFiles {
method, err := tryIdentityFile(id)
if err != nil {
return nil, err
}
methods = append(methods, method)
}
return methods, nil
}
// tryIdentityFile tries to use the given idendity file.
func tryIdentityFile(id string) (gossh.AuthMethod, error) {
h, err := home.ExpandPath(id)
if err != nil {
return nil, err //nolint: wrapcheck
}
return parsePrivateKey(h, nil)
}
// tryUserKeys will try to find id_rsa and id_ed25519 keys in the user $HOME/~.ssh folder.
func tryUserKeys() ([]gossh.AuthMethod, error) {
return tryUserKeysInternal(home.ExpandPath)
}
// https://github.com/openssh/openssh-portable/blob/8a0848cdd3b25c049332cd56034186b7853ae754/readconf.c#L2534-L2546
// https://github.com/openssh/openssh-portable/blob/2dc328023f60212cd29504fc05d849133ae47355/pathnames.h#L71-L81
func tryUserKeysInternal(pathResolver func(string) (string, error)) ([]gossh.AuthMethod, error) {
var methods []gossh.AuthMethod //nolint: prealloc
for _, name := range []string{
"id_rsa",
// "id_dsa", // unhandled by go, deprecated by openssh
"id_ecdsa",
"id_ecdsa_sk",
"id_ed25519",
"id_ed25519_sk",
// "id_xmss", // unhandled by go - and most openssh versions it seems
} {
path, err := pathResolver(filepath.Join("~/.ssh", name))
if err != nil {
return nil, err
}
method, err := parsePrivateKey(path, nil)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return methods, err
}
methods = append(methods, method)
}
return methods, nil
}
func parsePrivateKey(path string, password []byte) (gossh.AuthMethod, error) {
path, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("could not find key: %q: %w", path, err)
}
bts, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read key: %q: %w", path, err)
}
var signer gossh.Signer
if len(password) == 0 {
signer, err = gossh.ParsePrivateKey(bts)
} else {
signer, err = gossh.ParsePrivateKeyWithPassphrase(bts, password)
}
if err != nil {
pwderr := &gossh.PassphraseMissingError{}
if errors.As(err, &pwderr) {
fmt.Printf("Enter the password for %q: ", path)
password, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return nil, fmt.Errorf("failed to read password: %q", err)
}
return parsePrivateKey(path, password)
}
return nil, fmt.Errorf("failed to parse private key: %q: %w", path, err)
}
log.Info(
"offering public key",
"key.path", path,
"key.type", signer.PublicKey().Type(),
"key.fingerprint", gossh.FingerprintSHA256(signer.PublicKey()),
)
return gossh.PublicKeys(signer), nil
}
// hostKeyCallback returns a callback that will be used to verify the host key.
//
// it creates a file in the given path, and uses that to verify hosts and keys.
// if the host does not exist there, it adds it so its available next time, as plain old `ssh` does.
func hostKeyCallback(e *Endpoint, path string) gossh.HostKeyCallback {
return func(hostname string, remote net.Addr, key gossh.PublicKey) error {
kh, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:gomnd
if err != nil {
return fmt.Errorf("failed to open known_hosts: %w", err)
}
defer func() { _ = kh.Close() }()
callback, err := knownhosts.New(kh.Name())
if err != nil {
return fmt.Errorf("failed to check known_hosts: %w", err)
}
if err := callback(hostname, remote, key); err != nil {
var kerr *knownhosts.KeyError
if errors.As(err, &kerr) {
if len(kerr.Want) > 0 {
return fmt.Errorf("possible man-in-the-middle attack: %w - if your host's key changed, you might need to edit %q", err, kh.Name())
}
// if want is empty, it means the host was not in the known_hosts file, so lets add it there.
fmt.Fprintln(kh, knownhosts.Line([]string{e.Address}, key))
return nil
}
return fmt.Errorf("failed to check known_hosts: %w", err)
}
return nil
}
}
func askUser(in io.Reader, echo bool) (string, error) {
if !echo {
if f, ok := in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
bts, err := term.ReadPassword(int(f.Fd()))
if err != nil {
return "", fmt.Errorf("could not scan: %w", err)
}
return string(bts), nil
}
log.Warn("stdin is not a terminal, can't disable echo")
}
var answer string
if _, err := fmt.Fscan(in, &answer); err != nil {
return "", fmt.Errorf("could not scan: %w", err)
}
return answer, nil
}
// keyboardInteractiveAuth implements keyboard interactive authentication.
func keyboardInteractiveAuth(in io.Reader, out io.Writer) gossh.AuthMethod {
scan := func(q string, echo bool) (string, error) {
fmt.Fprint(out, q+" ")
answer, err := askUser(in, echo)
if err != nil {
return "", err
}
fmt.Fprintln(out)
return answer, nil
}
return gossh.KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {
fmt.Fprintln(out, name)
fmt.Fprintln(out, instruction)
for i, q := range questions {
answer, err := scan(q, echos[i])
if err != nil {
return nil, err
}
answers = append(answers, answer)
}
return answers, nil
})
}
func passwordAuth(e *Endpoint, in io.Reader, out io.Writer) (gossh.AuthMethod, error) {
fmt.Fprintf(out, "%s password: ", e.Address)
secret, err := askUser(in, false)
if err != nil {
return nil, fmt.Errorf("could not read password: %w", err)
}
return gossh.Password(secret), nil
}