diff --git a/kittens/ssh/askpass.go b/kittens/ssh/askpass.go index 854d84399..bd5d3a117 100644 --- a/kittens/ssh/askpass.go +++ b/kittens/ssh/askpass.go @@ -3,6 +3,10 @@ package ssh import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base32" + "encoding/binary" "encoding/json" "fmt" "os" @@ -34,6 +38,49 @@ func trigger_ask(name string) int { return 0 } +func isPasswordPrompt(msg string) bool { + q := strings.ToLower(msg) + if strings.Contains(q, "passphrase") { + return false + } + return strings.Contains(q, "password") +} + +func isOTPPrompt(msg string) bool { + q := strings.ToLower(msg) + if strings.Contains(q, "passphrase") { + return false + } + if strings.Contains(q, "verification code") || strings.Contains(q, "one-time password") || strings.Contains(q, "one time password") || strings.Contains(q, "authenticator code") || strings.Contains(q, "authentication code") || strings.Contains(q, "two-factor") || strings.Contains(q, "2fa") || strings.Contains(q, "otp") || strings.Contains(q, "passcode") { + return true + } + return false +} + +func generateTOTP(secret string, digits, period int64, t time.Time) (string, error) { + s := strings.ToUpper(strings.TrimSpace(secret)) + s = strings.ReplaceAll(s, " ", "") + key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(s) + if err != nil { + return "", fmt.Errorf("invalid TOTP secret: %w", err) + } + counter := uint64(t.Unix() / period) + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], counter) + mac := hmac.New(sha1.New, key) + _, _ = mac.Write(buf[:]) + sum := mac.Sum(nil) + off := sum[len(sum)-1] & 0x0f + code := (uint32(sum[off])&0x7f)<<24 | (uint32(sum[off+1])&0xff)<<16 | (uint32(sum[off+2])&0xff)<<8 | (uint32(sum[off+3]) & 0xff) + mod := uint32(1) + for i := int64(0); i < digits; i++ { + mod *= 10 + } + val := code % mod + fmtstr := fmt.Sprintf("%%0%dd", digits) + return fmt.Sprintf(fmtstr, val), nil +} + func RunSSHAskpass() int { msg := os.Args[len(os.Args)-1] prompt := os.Getenv("SSH_ASKPASS_PROMPT") @@ -43,6 +90,38 @@ func RunSSHAskpass() int { q_type = "confirm" } is_fingerprint_check := strings.Contains(msg, "(yes/no/[fingerprint])") + + // Auto-fill from ssh.conf if configured + if !is_confirm && !is_fingerprint_check { + host := os.Getenv("KITTY_SSH_ASKPASS_HOST") + user := os.Getenv("KITTY_SSH_ASKPASS_USER") + if host != "" { + if cfg, bad_lines, err := load_config(host, user, nil); err == nil && cfg != nil { + for _, bl := range bad_lines { + if bl.Err != nil { + // Only fail for our secret backend errors to avoid + // unrelated ssh.conf issues breaking askpass. + if strings.Contains(bl.Err.Error(), "Unsupported secret backend") { + fatal(bl.Err) + } + } + } + // Password autofill + if isPasswordPrompt(msg) && cfg.Password != "" { + fmt.Println(cfg.Password) + return 0 + } + // OTP autofill + if isOTPPrompt(msg) && cfg.Totp_secret != "" { + code, err := generateTOTP(cfg.Totp_secret, int64(cfg.Totp_digits), int64(cfg.Totp_period), time.Now()) + if err == nil { + fmt.Println(code) + return 0 + } + } + } + } + } q := map[string]any{ "message": msg, "type": q_type, diff --git a/kittens/ssh/config.go b/kittens/ssh/config.go index ee4167a8d..cfbbe54a0 100644 --- a/kittens/ssh/config.go +++ b/kittens/ssh/config.go @@ -24,6 +24,28 @@ import ( var _ = fmt.Print +// parseSecretSetting parses values of the form "backend:secret" for settings where +// only the "text" backend is currently supported. If no backend is specified, +// the value is treated as a plain text secret for backward compatibility. +func parseSecretSetting(settingKey, val string) (string, error) { + v := strings.TrimSpace(val) + if v == "" { + return "", nil + } + if b, s, ok := strings.Cut(v, ":"); ok { + b = strings.ToLower(strings.TrimSpace(b)) + s = strings.TrimSpace(s) + switch b { + case "text": + return s, nil + default: + return "", fmt.Errorf("Unsupported secret backend %q for %s. Supported backends: text", b, settingKey) + } + } + // No backend specified; treat as text + return v, nil +} + type EnvInstruction struct { key, val string delete_on_remote, copy_from_local, literal_quote bool @@ -379,6 +401,22 @@ func (self *ConfigSet) line_handler(key, val string) error { c = NewConfig() self.all_configs = append(self.all_configs, c) } + switch key { + case "password": + secret, err := parseSecretSetting("password", val) + if err != nil { + return err + } + c.Password = secret + return nil + case "totp_secret": + secret, err := parseSecretSetting("totp_secret", val) + if err != nil { + return err + } + c.Totp_secret = secret + return nil + } return c.Parse(key, val) } @@ -400,5 +438,16 @@ func load_config(hostname_to_match string, username_to_match string, overrides [ bad_lines = append(bad_lines, override_parser.BadLines()...) final_conf.Hostname = h } + // Normalize and validate secrets post-overrides as overrides bypass line_handler + if s, err := parseSecretSetting("password", final_conf.Password); err != nil { + bad_lines = append(bad_lines, config.ConfigLine{Src_file: "", Line: "password " + final_conf.Password, Line_number: 0, Err: err}) + } else { + final_conf.Password = s + } + if s, err := parseSecretSetting("totp_secret", final_conf.Totp_secret); err != nil { + bad_lines = append(bad_lines, config.ConfigLine{Src_file: "", Line: "totp_secret " + final_conf.Totp_secret, Line_number: 0, Err: err}) + } else { + final_conf.Totp_secret = s + } return final_conf, bad_lines, nil } diff --git a/kittens/ssh/main.go b/kittens/ssh/main.go index c60285b54..1f1ae53e1 100644 --- a/kittens/ssh/main.go +++ b/kittens/ssh/main.go @@ -646,11 +646,14 @@ func run_ssh(ssh_args, server_args, found_extra_args []string) (rc int, err erro } cmd = slices.Insert(cmd, insertion_point, control_master_args...) } - use_kitty_askpass := host_opts.Askpass == Askpass_native || (host_opts.Askpass == Askpass_unless_set && os.Getenv("SSH_ASKPASS") == "") - need_to_request_data := true - if use_kitty_askpass { - need_to_request_data = set_askpass() - } + use_kitty_askpass := host_opts.Askpass == Askpass_native || (host_opts.Askpass == Askpass_unless_set && os.Getenv("SSH_ASKPASS") == "") + need_to_request_data := true + if use_kitty_askpass { + need_to_request_data = set_askpass() + // Provide host/user to askpass so it can lookup auth settings in ssh.conf + os.Setenv("KITTY_SSH_ASKPASS_HOST", hostname_for_match) + os.Setenv("KITTY_SSH_ASKPASS_USER", uname) + } master_is_functional := func() bool { if master_checked { return master_is_alive diff --git a/kittens/ssh/main.py b/kittens/ssh/main.py index 567aacdd7..d445784db 100644 --- a/kittens/ssh/main.py +++ b/kittens/ssh/main.py @@ -220,6 +220,40 @@ environment variable. egr() # }}} +agr('askpass', 'Askpass automation') # {{{ + +opt('password', '', long_text=''' +Specify a secret to use when SSH prompts for a password. The value format is +"backend:secret". Currently, only the "text" backend is supported, which stores +the secret in plain text in the config file. For example: + + password text:my_password + +If the backend prefix is omitted, it is treated as "text:" for backward +compatibility. Beware that storing passwords in plain text is insecure. +''') + +opt('totp_secret', '', long_text=''' +Specify a TOTP shared secret to auto-fill one-time codes when SSH asks for them. +The value format is "backend:secret". Currently, only the "text" backend is +supported. For example: + + totp_secret text:JBSWY3DPEHPK3PXP + +If the backend prefix is omitted, it is treated as "text:" for backward +compatibility. +''') + +opt('totp_digits', '6', option_type='int', long_text=''' +Number of digits for the generated TOTP codes. Default is 6. +''') + +opt('totp_period', '30', option_type='int', long_text=''' +Time period in seconds for the TOTP code validity. Default is 30. +''') + +egr() # }}} + def main(args: list[str]) -> str | None: raise SystemExit('This should be run as kitten ssh')