diff --git a/README.md b/README.md index 19ce67d..bc5ff59 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ trzsz-ssh ( tssh ) works exactly like the openssh client. The following common f | Features | Support Options | | :------------: | :----------------------------------------------------------------------------------------------------------------: | +| Cipher | `-c` `Ciphers` | | Pseudo TTY | `-t` `-T` `RequestTTY` | | SSH Proxy | `-J` `-W` `ProxyJump` `ProxyCommand` | | Multiplexing | `ControlMaster` `ControlPath` `ControlPersist` | @@ -163,7 +164,7 @@ trzsz-ssh ( tssh ) offers additional useful features: -- Download from the [Releases](https://github.com/trzsz/trzsz-ssh/releases) +- Download from the [GitHub Releases](https://github.com/trzsz/trzsz-ssh/releases), unzip and add to `PATH` environment. ## Contributing diff --git a/tssh/args.go b/tssh/args.go index 079b8e5..5eade96 100644 --- a/tssh/args.go +++ b/tssh/args.go @@ -61,6 +61,7 @@ type sshArgs struct { Port int `arg:"-p,--" placeholder:"port" help:"port to connect to on the remote host"` LoginName string `arg:"-l,--" placeholder:"login_name" help:"the user to log in as on the remote machine"` Identity multiStr `arg:"-i,--" placeholder:"identity_file" help:"identity (private key) for public key auth"` + CipherSpec string `arg:"-c,--" placeholder:"cipher_spec" help:"cipher specification for encrypting the session"` ConfigFile string `arg:"-F,--" placeholder:"configfile" help:"an alternative per-user configuration file"` ProxyJump string `arg:"-J,--" placeholder:"destination" help:"jump hosts separated by comma characters"` Option sshOption `arg:"-o,--" placeholder:"key=value" help:"options in the format used in ~/.ssh/config\ne.g., tssh -o ProxyCommand=\"ssh proxy nc %h %p\""` diff --git a/tssh/args_test.go b/tssh/args_test.go index bb8ad13..cf01980 100644 --- a/tssh/args_test.go +++ b/tssh/args_test.go @@ -66,6 +66,8 @@ func TestSshArgs(t *testing.T) { assertArgsEqual("-i id_rsa", sshArgs{Identity: multiStr{values: []string{"id_rsa"}}}) assertArgsEqual("-i ./id_rsa -i /tmp/id_ed25519", sshArgs{Identity: multiStr{[]string{"./id_rsa", "/tmp/id_ed25519"}}}) + assertArgsEqual("-c+aes128-cbc", sshArgs{CipherSpec: "+aes128-cbc"}) + assertArgsEqual("-c ^aes128-cbc,3des-cbc", sshArgs{CipherSpec: "^aes128-cbc,3des-cbc"}) assertArgsEqual("-Fcfg", sshArgs{ConfigFile: "cfg"}) assertArgsEqual("-F /path/to/cfg", sshArgs{ConfigFile: "/path/to/cfg"}) assertArgsEqual("-Jjump", sshArgs{ProxyJump: "jump"}) diff --git a/tssh/cipher.go b/tssh/cipher.go new file mode 100644 index 0000000..3a3cdcb --- /dev/null +++ b/tssh/cipher.go @@ -0,0 +1,146 @@ +package tssh + +/* +MIT License + +Copyright (c) 2023-2024 The Trzsz SSH Authors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +import ( + "fmt" + "regexp" + "strings" + + "golang.org/x/crypto/ssh" +) + +func debugCiphersConfig(config *ssh.ClientConfig) { + if !enableDebugLogging { + return + } + debug("user declared ciphers: %v", config.Ciphers) + config.SetDefaults() + debug("client supported ciphers: %v", config.Ciphers) +} + +func appendCiphersConfig(config *ssh.ClientConfig, cipherSpec string) error { + config.SetDefaults() + for _, cipher := range strings.Split(cipherSpec, ",") { + cipher = strings.TrimSpace(cipher) + if cipher != "" { + config.Ciphers = append(config.Ciphers, cipher) + } + } + debugCiphersConfig(config) + return nil +} + +func removeCiphersConfig(config *ssh.ClientConfig, cipherSpec string) error { + var buf strings.Builder + for _, cipher := range strings.Split(cipherSpec, ",") { + if buf.Len() > 0 { + buf.WriteRune('|') + } + buf.WriteString("(^") + for _, c := range cipher { + switch c { + case '*': + buf.WriteString(".*") + case '?': + buf.WriteRune('.') + case '(', ')', '[', ']', '{', '}', '.', '+', ',', '-', '^', '$', '|', '\\': + buf.WriteRune('\\') + buf.WriteRune(c) + default: + buf.WriteRune(c) + } + } + buf.WriteString("$)") + } + expr := buf.String() + debug("ciphers regexp: %s", expr) + re, err := regexp.Compile(expr) + if err != nil { + return fmt.Errorf("compile ciphers regexp failed: %v", err) + } + + config.SetDefaults() + ciphers := make([]string, 0) + for _, cipher := range config.Ciphers { + if re.MatchString(cipher) { + continue + } + ciphers = append(ciphers, cipher) + } + config.Ciphers = ciphers + debugCiphersConfig(config) + return nil +} + +func insertCiphersConfig(config *ssh.ClientConfig, cipherSpec string) error { + var ciphers []string + for _, cipher := range strings.Split(cipherSpec, ",") { + cipher = strings.TrimSpace(cipher) + if cipher != "" { + ciphers = append(ciphers, cipher) + } + } + config.SetDefaults() + config.Ciphers = append(ciphers, config.Ciphers...) + debugCiphersConfig(config) + return nil +} + +func replaceCiphersConfig(config *ssh.ClientConfig, cipherSpec string) error { + config.Ciphers = nil + for _, cipher := range strings.Split(cipherSpec, ",") { + cipher = strings.TrimSpace(cipher) + if cipher != "" { + config.Ciphers = append(config.Ciphers, cipher) + } + } + debugCiphersConfig(config) + return nil +} + +func getCiphersConfig(args *sshArgs) string { + if args.CipherSpec != "" { + return args.CipherSpec + } + return getOptionConfig(args, "Ciphers") +} + +func setupCiphersConfig(args *sshArgs, config *ssh.ClientConfig) error { + cipherSpec := getCiphersConfig(args) + if cipherSpec == "" { + return nil + } + switch cipherSpec[0] { + case '+': + return appendCiphersConfig(config, cipherSpec[1:]) + case '-': + return removeCiphersConfig(config, cipherSpec[1:]) + case '^': + return insertCiphersConfig(config, cipherSpec[1:]) + default: + return replaceCiphersConfig(config, cipherSpec) + } +} diff --git a/tssh/login.go b/tssh/login.go index 50667dd..0b1324d 100644 --- a/tssh/login.go +++ b/tssh/login.go @@ -1026,6 +1026,10 @@ func sshConnect(args *sshArgs, client *ssh.Client, proxy string) (*ssh.Client, * }, } + if err := setupCiphersConfig(args, config); err != nil { + return nil, param, false, err + } + proxyConnect := func(client *ssh.Client, proxy string) (*ssh.Client, *sshParam, bool, error) { debug("login to [%s], addr: %s", args.Destination, param.addr) conn, err := dialWithTimeout(client, "tcp", param.addr, 10*time.Second)