mirror of
https://github.com/kovidgoyal/kitty
synced 2026-07-07 17:43:53 +02:00
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:
committed by
Kovid Goyal
parent
a11bc34a44
commit
d02c63ac86
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user