From d02c63ac865dd4f1b082834586e2c774cacaf61e Mon Sep 17 00:00:00 2001 From: NightWatcher Date: Sat, 27 Sep 2025 20:22:12 +0800 Subject: [PATCH] ssh kitten: add optional password and TOTP auto-fill via ssh.conf Motivation: Some environments disallow or do not reliably accept one-way pubkey-only auth, or require keyboard-interactive password + TOTP. This adds an optional, host-scoped automation via kitty's native askpass to reduce repetitive manual entry while preserving the ssh kitten UX. - Add auth_config.go to parse password/totp_* from ssh.conf by host block - Ignore these keys in main ssh.conf parser to avoid bad-line warnings - Pass host/user to askpass for host-aware lookup - Auto-answer password and OTP prompts in askpass; fallback to UI otherwise Security: Secrets in ssh.conf are plain text; users should enforce strict permissions or avoid storing passwords if unacceptable. Only login password/OTP prompts are auto-answered; passphrases and host key confirmations are not. feat(ssh): add secret backend support for auth passwords and TOTP secrets Introduce support for specifying secret backends in SSH auth config, currently supporting only the "text" backend for storing secrets directly. This allows for future extensibility while maintaining backward compatibility by treating values without a backend as "text:". The changes include new fields in AuthEntry for backends, updated parsing logic in lineHandler, error handling for invalid backends, and normalization for existing configs. A new parseBackendSecret function handles the parsing with validation. --- kittens/ssh/askpass.go | 79 ++++++++++++++++++++++++++++++++++++++++++ kittens/ssh/config.go | 49 ++++++++++++++++++++++++++ kittens/ssh/main.go | 13 ++++--- kittens/ssh/main.py | 34 ++++++++++++++++++ 4 files changed, 170 insertions(+), 5 deletions(-) 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')