返回 DeepSeek-Reasonix
auth.go
根目录 / internal / remote / auth.go
1 package remote
2
3 import (
4 "bytes"
5 "context"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11
12 "golang.org/x/crypto/ssh"
13 "golang.org/x/crypto/ssh/agent"
14 )
15
16 // SecretKind identifies which interactive secret is being requested.
17 type SecretKind int
18
19 const (
20 SecretPassphrase SecretKind = iota // private-key passphrase
21 SecretPassword // password auth
22 )
23
24 func (k SecretKind) String() string {
25 if k == SecretPassword {
26 return "password"
27 }
28 return "passphrase"
29 }
30
31 // SecretPrompt obtains a one-shot credential without persisting or publishing
32 // it. Implementations should respect ctx cancellation when the connection is
33 // stopped or superseded.
34 type SecretPrompt func(ctx context.Context, kind SecretKind, host, identityFile string) (string, error)
35
36 // AuthOptions supplies credential resolution for a dial. Passphrase and
37 // Password return already-resolved credential-store values (nil when none is
38 // configured). SecretPrompt is the interactive fallback — a terminal prompt in
39 // the CLI, a dialog in the desktop — and is only ever called on the first
40 // connect; reconnects reuse in-memory-cached secrets and never prompt.
41 type AuthOptions struct {
42 Passphrase func() (string, error)
43 Password func() (string, error)
44 SecretPrompt SecretPrompt
45 DisableAgent bool
46
47 // cache holds secrets obtained during the first connect so the supervisor
48 // can reconnect silently. Populated by the auth methods.
49 cache *secretCache
50 }
51
52 type secretCache struct {
53 passphrases map[string]string
54 password string
55 havePw bool
56 }
57
58 // buildAuthMethods assembles authentication in OpenSSH-like order: agent,
59 // explicit identity file (or default identities), password, then
60 // keyboard-interactive. Public-key sources are returned through an AuthCallback
61 // because x/crypto/ssh deliberately uses only the first static AuthMethod for a
62 // protocol method. Without the callback, an empty or rejected agent consumes
63 // "publickey" and the configured identity file is never attempted.
64 //
65 // Password methods are only offered when a stored credential or interactive
66 // prompt exists. Otherwise a rejected public key must remain a public-key
67 // authentication failure instead of being masked by a misleading "password
68 // required but no prompt available" callback error.
69 func buildAuthMethods(ctx context.Context, h ResolvedHost, opts *AuthOptions) ([]ssh.AuthMethod, ssh.ClientAuthCallback, func(), error) {
70 if opts.cache == nil {
71 opts.cache = &secretCache{}
72 }
73 var publicKeys []ssh.AuthMethod
74 var fallback []ssh.AuthMethod
75 cleanup := func() {}
76
77 identityFiles := append([]string(nil), h.IdentityFiles...)
78 if len(identityFiles) == 0 && h.IdentityFile != "" {
79 identityFiles = []string{h.IdentityFile}
80 }
81 if len(identityFiles) == 0 && !h.IdentityFileNone {
82 identityFiles = defaultIdentityFiles()
83 }
84
85 if !opts.DisableAgent {
86 if am, closeAgent := agentAuth(identityFiles, h.IdentitiesOnly); am != nil {
87 publicKeys = append(publicKeys, am)
88 cleanup = closeAgent
89 }
90 }
91
92 if len(identityFiles) > 0 {
93 for _, identityFile := range identityFiles {
94 am, err := keyAuth(ctx, h, opts, identityFile, len(identityFiles) > 1)
95 if err != nil {
96 // Preserve the old explicit-single-key behavior, but let an
97 // OpenSSH identity list continue to its remaining candidates.
98 if len(identityFiles) == 1 {
99 cleanup()
100 return nil, nil, func() {}, err
101 }
102 continue
103 }
104 if am != nil {
105 publicKeys = append(publicKeys, am)
106 }
107 }
108 }
109
110 if opts.Password != nil || opts.SecretPrompt != nil {
111 fallback = append(fallback, passwordAuth(ctx, h, opts))
112 fallback = append(fallback, keyboardInteractiveAuth(ctx, h, opts))
113 }
114 return fallback, publicKeyAuthCallback(publicKeys), cleanup, nil
115 }
116
117 // publicKeyAuthCallback returns each public-key source exactly once while the
118 // server continues to allow publickey authentication. AuthCallback may return
119 // multiple AuthMethod values with the same protocol name, unlike ClientConfig's
120 // static Auth slice.
121 func publicKeyAuthCallback(methods []ssh.AuthMethod) ssh.ClientAuthCallback {
122 if len(methods) == 0 {
123 return nil
124 }
125 next := 0
126 return func(ctx *ssh.ClientAuthContext) (ssh.AuthMethod, error) {
127 if next >= len(methods) || !containsAuthMethod(ctx.AllowedMethods, "publickey") {
128 return nil, nil
129 }
130 method := methods[next]
131 next++
132 return method, nil
133 }
134 }
135
136 func containsAuthMethod(methods []string, want string) bool {
137 for _, method := range methods {
138 if method == want {
139 return true
140 }
141 }
142 return false
143 }
144
145 func agentAuth(identityFiles []string, identitiesOnly bool) (ssh.AuthMethod, func()) {
146 sock := os.Getenv("SSH_AUTH_SOCK")
147 if sock == "" {
148 return nil, func() {}
149 }
150 var mu sync.Mutex
151 var conns []interface{ Close() error }
152 method := ssh.PublicKeysCallback(func() ([]ssh.Signer, error) {
153 conn, err := dialAgent(sock)
154 if err != nil {
155 return nil, err
156 }
157 mu.Lock()
158 conns = append(conns, conn)
159 mu.Unlock()
160 signers, err := agent.NewClient(conn).Signers()
161 if err != nil {
162 return nil, err
163 }
164 if identitiesOnly {
165 signers = filterAgentSigners(signers, identityFiles)
166 }
167 return signers, nil
168 })
169 return method, func() {
170 mu.Lock()
171 owned := conns
172 conns = nil
173 mu.Unlock()
174 for _, conn := range owned {
175 _ = conn.Close()
176 }
177 }
178 }
179
180 // filterAgentSigners implements OpenSSH's IdentitiesOnly behavior: agent keys
181 // remain available when they correspond to a configured IdentityFile, but
182 // unrelated agent keys are not offered to the server.
183 func filterAgentSigners(signers []ssh.Signer, identityFiles []string) []ssh.Signer {
184 allowed := make([]ssh.PublicKey, 0, len(identityFiles))
185 for _, path := range identityFiles {
186 allowed = append(allowed, identityPublicKeys(path)...)
187 }
188 if len(allowed) == 0 {
189 return nil
190 }
191 filtered := make([]ssh.Signer, 0, len(signers))
192 for _, signer := range signers {
193 for _, key := range allowed {
194 if publicKeysEqual(signer.PublicKey(), key) {
195 filtered = append(filtered, signer)
196 break
197 }
198 }
199 }
200 return filtered
201 }
202
203 func identityPublicKeys(path string) []ssh.PublicKey {
204 path = expandHome(path)
205 candidates := []string{path}
206 if !strings.HasSuffix(strings.ToLower(path), ".pub") {
207 candidates = append(candidates, path+".pub")
208 }
209 seen := map[string]bool{}
210 var keys []ssh.PublicKey
211 for _, candidate := range candidates {
212 data, err := os.ReadFile(candidate)
213 if err != nil {
214 continue
215 }
216 if key, _, _, _, err := ssh.ParseAuthorizedKey(data); err == nil {
217 id := string(normalizePublicKey(key).Marshal())
218 if !seen[id] {
219 seen[id] = true
220 keys = append(keys, key)
221 }
222 continue
223 }
224 if signer, err := ssh.ParsePrivateKey(data); err == nil {
225 key := signer.PublicKey()
226 id := string(normalizePublicKey(key).Marshal())
227 if !seen[id] {
228 seen[id] = true
229 keys = append(keys, key)
230 }
231 continue
232 } else {
233 var missing *ssh.PassphraseMissingError
234 if isPassphraseMissing(err, &missing) && missing.PublicKey != nil {
235 key := missing.PublicKey
236 id := string(normalizePublicKey(key).Marshal())
237 if !seen[id] {
238 seen[id] = true
239 keys = append(keys, key)
240 }
241 }
242 }
243 }
244 return keys
245 }
246
247 func publicKeysEqual(a, b ssh.PublicKey) bool {
248 return bytes.Equal(normalizePublicKey(a).Marshal(), normalizePublicKey(b).Marshal())
249 }
250
251 func normalizePublicKey(key ssh.PublicKey) ssh.PublicKey {
252 if cert, ok := key.(*ssh.Certificate); ok {
253 return cert.Key
254 }
255 return key
256 }
257
258 // keyAuth loads a private key, resolving a passphrase from the credential
259 // store then the interactive prompt when the key is encrypted. Returns nil
260 // (no method, no error) when the key file simply does not exist.
261 func keyAuth(ctx context.Context, h ResolvedHost, opts *AuthOptions, path string, allowDecryptSkip bool) (ssh.AuthMethod, error) {
262 path = expandHome(path)
263 pem, err := os.ReadFile(path)
264 if err != nil {
265 if os.IsNotExist(err) {
266 return nil, nil
267 }
268 return nil, err
269 }
270 signer, err := ssh.ParsePrivateKey(pem)
271 if err == nil {
272 return ssh.PublicKeys(signer), nil
273 }
274 // OpenSSH permits IdentityFile to name a public key when the matching
275 // private key lives in ssh-agent. The filtered agent method above handles it.
276 if _, _, _, _, publicErr := ssh.ParseAuthorizedKey(pem); publicErr == nil {
277 return nil, nil
278 }
279 var missing *ssh.PassphraseMissingError
280 if !isPassphraseMissing(err, &missing) {
281 return nil, fmt.Errorf("parse key %s: %w", path, err)
282 }
283 // Encrypted key: return a lazy method so the passphrase is only resolved
284 // if the server actually offers publickey with this key.
285 return ssh.PublicKeysCallback(func() ([]ssh.Signer, error) {
286 pass, perr := resolvePassphrase(ctx, h, opts, path)
287 if perr != nil {
288 return nil, perr
289 }
290 s, serr := ssh.ParsePrivateKeyWithPassphrase(pem, []byte(pass))
291 if serr != nil && opts.SecretPrompt != nil {
292 // A host-level stored passphrase may unlock only one member of an
293 // IdentityFile list. Give this identity its own one-shot prompt before
294 // deciding that it is unavailable.
295 delete(opts.cache.passphrases, path)
296 pass, perr = opts.SecretPrompt(ctx, SecretPassphrase, h.Label(), path)
297 if perr != nil {
298 return nil, perr
299 }
300 opts.cache.passphrases[path] = pass
301 s, serr = ssh.ParsePrivateKeyWithPassphrase(pem, []byte(pass))
302 }
303 if serr != nil {
304 delete(opts.cache.passphrases, path)
305 // A configured identity list may contain encrypted keys with different
306 // passphrases. Treat a failed decryption like an unavailable identity so
307 // the next key can still be attempted; preserve the focused error for an
308 // explicit single-key configuration.
309 if allowDecryptSkip {
310 return nil, nil
311 }
312 return nil, fmt.Errorf("decrypt key %s: %w", path, serr)
313 }
314 return []ssh.Signer{s}, nil
315 }), nil
316 }
317
318 func resolvePassphrase(ctx context.Context, h ResolvedHost, opts *AuthOptions, identityFile string) (string, error) {
319 if opts.cache.passphrases == nil {
320 opts.cache.passphrases = map[string]string{}
321 }
322 if passphrase, ok := opts.cache.passphrases[identityFile]; ok {
323 return passphrase, nil
324 }
325 if opts.Passphrase != nil {
326 v, err := opts.Passphrase()
327 if err != nil {
328 return "", err
329 }
330 if v != "" {
331 opts.cache.passphrases[identityFile] = v
332 return v, nil
333 }
334 }
335 if opts.SecretPrompt == nil {
336 return "", fmt.Errorf("remote: key passphrase required but no prompt available")
337 }
338 v, err := opts.SecretPrompt(ctx, SecretPassphrase, h.Label(), identityFile)
339 if err != nil {
340 return "", err
341 }
342 opts.cache.passphrases[identityFile] = v
343 return v, nil
344 }
345
346 func passwordAuth(ctx context.Context, h ResolvedHost, opts *AuthOptions) ssh.AuthMethod {
347 return ssh.RetryableAuthMethod(ssh.PasswordCallback(func() (string, error) {
348 return resolvePassword(ctx, h, opts)
349 }), 3)
350 }
351
352 func keyboardInteractiveAuth(ctx context.Context, h ResolvedHost, opts *AuthOptions) ssh.AuthMethod {
353 return ssh.KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) {
354 // Never copy a password into echoed, OTP, or multi-question prompts.
355 // The current callback models only a password secret, so support the
356 // common single hidden-password challenge and fail closed otherwise.
357 if len(questions) != 1 || len(echos) != 1 || echos[0] {
358 return nil, fmt.Errorf("remote: unsupported keyboard-interactive challenge from %s", h.Label())
359 }
360 pw, err := resolvePassword(ctx, h, opts)
361 if err != nil {
362 return nil, err
363 }
364 return []string{pw}, nil
365 })
366 }
367
368 func resolvePassword(ctx context.Context, h ResolvedHost, opts *AuthOptions) (string, error) {
369 if opts.cache.havePw {
370 return opts.cache.password, nil
371 }
372 if opts.Password != nil {
373 v, err := opts.Password()
374 if err != nil {
375 return "", err
376 }
377 if v != "" {
378 opts.cache.password, opts.cache.havePw = v, true
379 return v, nil
380 }
381 }
382 if opts.SecretPrompt == nil {
383 return "", fmt.Errorf("remote: password required but no prompt available")
384 }
385 v, err := opts.SecretPrompt(ctx, SecretPassword, h.Label(), "")
386 if err != nil {
387 return "", err
388 }
389 opts.cache.password, opts.cache.havePw = v, true
390 return v, nil
391 }
392
393 func defaultIdentityFiles() []string {
394 home, err := os.UserHomeDir()
395 if err != nil {
396 return nil
397 }
398 names := []string{"id_ed25519", "id_ecdsa", "id_rsa"}
399 out := make([]string, 0, len(names))
400 for _, n := range names {
401 out = append(out, filepath.Join(home, ".ssh", n))
402 }
403 return out
404 }
405
406 func isPassphraseMissing(err error, target **ssh.PassphraseMissingError) bool {
407 if pe, ok := err.(*ssh.PassphraseMissingError); ok {
408 *target = pe
409 return true
410 }
411 return false
412 }
413
413 lines GO