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:<value>".

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.
This commit is contained in:
NightWatcher
2025-09-27 20:22:12 +08:00
committed by Kovid Goyal
parent a11bc34a44
commit d02c63ac86
4 changed files with 170 additions and 5 deletions

View File

@@ -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,

View File

@@ -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: "<overrides>", 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: "<overrides>", Line: "totp_secret " + final_conf.Totp_secret, Line_number: 0, Err: err})
} else {
final_conf.Totp_secret = s
}
return final_conf, bad_lines, nil
}

View File

@@ -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

View File

@@ -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')