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