From 2f22aaf658f2b45e1c2515dc18d0fa1c937f20bb Mon Sep 17 00:00:00 2001 From: Lonny Wong Date: Sun, 4 Feb 2024 15:12:27 +0800 Subject: [PATCH] built-in support totp --- README.cn.md | 31 ++++++++++++++++++++++- README.en.md | 31 ++++++++++++++++++++++- go.mod | 4 ++- go.sum | 10 ++++++-- tssh/expect.go | 13 ++++++++++ tssh/login.go | 41 ++++++++++-------------------- tssh/otp.go | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 167 insertions(+), 32 deletions(-) create mode 100644 tssh/otp.go diff --git a/README.cn.md b/README.cn.md index aa7a8c2..88d6fc9 100644 --- a/README.cn.md +++ b/README.cn.md @@ -404,6 +404,18 @@ trzsz-ssh ( tssh ) 设计为 ssh 客户端的直接替代品,提供与 openssh #!! ExpectCaseSendPass1 token d7... # 在 ExpectPattern1 匹配之前,若遇到 token 则解码 d7... 并发送 ``` +- 在匹配到指定输出时,自动生成 `totp` 2FA 双因子验证码,然后自动输入,用法如下: + + ``` + Host totp + #!! ExpectCount 2 # 配置自动交互的次数,默认是 0 即无自动交互 + #!! ExpectPattern1 token: # 配置第一个自动交互的匹配表达式 + #!! ExpectSendTotp1 xxxxx # 配置 totp 的 secret(明文),一般可通过扫二维码获得 + #!! ExpectPattern2 token: # 配置第二个自动交互的匹配表达式 + # 下面是运行 tssh --enc-secret 输入 totp 的 secret 得到的密文串 + #!! ExpectSendEncTotp2 821fe830270201c36cd1a869876a24453014ac2f1d2d3b056f3601ce9cc9a87023 + ``` + - 在匹配到指定输出时,执行指定的命令获取动态密码,然后自动输入,用法如下: ``` @@ -521,6 +533,17 @@ trzsz-ssh ( tssh ) 设计为 ssh 客户端的直接替代品,提供与 openssh 636f64653a20 my_code # 其中 `636f64653a20` 是问题 `code: ` 的 hex 编码, `my_code` 是明文答案 ``` +- 对于 `totp` 2FA 双因子验证码,则可以如下配置(同样支持按序号或 hex 编码进行配置): + + ``` + Host totp + TotpSecret1 xxxxx # 按序号配置 totp 的 secret(明文),一般可通过扫二维码获得 + totp636f64653a20 xxxxx # 按 `code: ` 的 hex 编码 `636f64653a20` 配置 totp 的 secret(明文) + # 下面是运行 tssh --enc-secret 输入命令 xxxxx 得到的密文串,加上 `enc` 前缀进行配置 + encTotpSecret2 8ba828bd54ff694bc8c4619f802b5bed73232e60a680bbac05ba5626269a81a00b + enctotp636f64653a20 8ba828bd54ff694bc8c4619f802b5bed73232e60a680bbac05ba5626269a81a00b + ``` + - 对于可以通过命令行获取到的动态密码,则可以如下配置(同样支持按序号或 hex 编码进行配置): ``` @@ -535,8 +558,14 @@ trzsz-ssh ( tssh ) 设计为 ssh 客户端的直接替代品,提供与 openssh - 如果启用了 `ControlMaster` 多路复用,或者是在 `Warp` 终端,请参考前面 `自动交互` 加 `Ctrl` 前缀来实现。 ``` + Host ctrl_totp + #!! CtrlExpectCount 1 # 配置自动交互的次数 + #!! CtrlExpectPattern1 code: # 配置密码提示语的匹配表达式(这里以 2FA 验证码举例) + #!! CtrlExpectSendTotp1 xxxxx # 配置 totp 的 secret(明文),一般可通过扫二维码获得 + #!! CtrlExpectSendEncTotp1 622ada31cf... # 或者配置 tssh --enc-secret 得到的密文串 + Host ctrl_otp - #!! CtrlExpectCount 1 # 配置自动交互的次数,一般只要输入一次密码 + #!! CtrlExpectCount 1 # 配置自动交互的次数 #!! CtrlExpectPattern1 token: # 配置密码提示语的匹配表达式(这里以动态密码举例) #!! CtrlExpectSendOtp1 oathtool --totp -b xxxxx # 配置获取动态密码的命令(明文) #!! CtrlExpectSendEncOtp1 77b4ce85d0... # 或者配置 tssh --enc-secret 得到的密文串 diff --git a/README.en.md b/README.en.md index 464b102..bed410f 100644 --- a/README.en.md +++ b/README.en.md @@ -404,6 +404,18 @@ trzsz-ssh ( tssh ) is an ssh client designed as a drop-in replacement for the op #!! ExpectCaseSendPass1 token d7... # Before matching ExpectPattern1, if encountering token, then decode d7... and send ``` +- When the server's output is matched, generate the `totp` 2FA code, and send it: + + ``` + Host totp + #!! ExpectCount 2 # Configures the number of automated interactions, default is 0 which means no automated interaction + #!! ExpectPattern1 token: # Configures the first automated interaction match expression + #!! ExpectSendTotp1 xxxxx # Configure the secret (plain text) of totp, generally obtained by scanning the QR code + #!! ExpectPattern2 token: # Configures the second automated interaction match expression + # The following ciphertext was generated by encoding the secret of totp with `tssh --enc-secret`. + #!! ExpectSendEncTotp2 821fe830270201c36cd1a869876a24453014ac2f1d2d3b056f3601ce9cc9a87023 + ``` + - When the server's output is matched, execute the specified command to obtain the one-time password, and send it: ``` @@ -521,6 +533,17 @@ trzsz-ssh ( tssh ) is an ssh client designed as a drop-in replacement for the op 636f64653a20 my_code # The `636f64653a20` is the hex code of `code: `, `my_code` is plain answer. ``` +- For `totp` 2FA code, you can configure them as follows (configure by serial number or hex code of the question): + + ``` + Host totp + TotpSecret1 xxxxx # Configure the secret (plain text) of totp by serial number + totp636f64653a20 xxxxx # Configure the secret of totp by the hex code of the question `code: ` that is `636f64653a20` + # The following ciphertext was generated by encoding the secret of totp with `tssh --enc-secret`. Add the `enc` prefix for configuration. + encTotpSecret2 8ba828bd54ff694bc8c4619f802b5bed73232e60a680bbac05ba5626269a81a00b + enctotp636f64653a20 8ba828bd54ff694bc8c4619f802b5bed73232e60a680bbac05ba5626269a81a00b + ``` + - For one-time password that can be obtained by the command line, you can configure them as follows (configure by serial number or hex code of the question): ``` @@ -535,8 +558,14 @@ trzsz-ssh ( tssh ) is an ssh client designed as a drop-in replacement for the op - If `ControlMaster` multiplexing is enabled or using `Warp` terminal, you will need to use the `Automated Interaction` mentioned earlier to achieve remembering answers. ``` + Host ctrl_totp + #!! CtrlExpectCount 1 # Configure the number of automated interactions + #!! CtrlExpectPattern1 code: # Configure the matching expression for the password prompt (totp 2FA) + #!! CtrlExpectSendTotp1 xxxxx # Configure the secret (plain text) of totp, generally obtained by scanning the QR code + #!! CtrlExpectSendEncTotp1 622ada31cf... # Or configure the encrypted secret of totp encoded using `tssh --enc-secret` + Host ctrl_otp - #!! CtrlExpectCount 1 # Configure the number of automated interactions, typically only requires entering the password once + #!! CtrlExpectCount 1 # Configure the number of automated interactions #!! CtrlExpectPattern1 token: # Configure the matching expression for the password prompt (one-time password) #!! CtrlExpectSendOtp1 oathtool --totp -b xxxxx # Configure the command line to obtain the one-time password #!! CtrlExpectSendEncOtp1 77b4ce85d0... # Or configure the encrypted command line encoded using `tssh --enc-secret` diff --git a/go.mod b/go.mod index 888b08b..7353eca 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/Microsoft/go-winio v0.6.1 github.com/alessio/shellescape v1.4.2 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 - github.com/charmbracelet/bubbles v0.17.1 + github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.25.0 github.com/charmbracelet/lipgloss v0.9.1 github.com/chzyer/readline v1.5.1 @@ -15,6 +15,7 @@ require ( github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-runewidth v0.0.15 github.com/mitchellh/go-homedir v1.1.0 + github.com/pquerna/otp v1.4.0 github.com/skeema/knownhosts v1.2.1 github.com/stretchr/testify v1.8.4 github.com/trzsz/go-arg v1.5.3 @@ -34,6 +35,7 @@ require ( github.com/andybrewer/mack v0.0.0-20220307193339-22e922cc18af // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/boombuler/barcode v1.0.1 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f // indirect diff --git a/go.sum b/go.sum index 5c8adab..4c9eba1 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,11 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v0.17.1 h1:0SIyjOnkrsfDo88YvPgAWvZMwXe26TP6drRvmkjyUu4= -github.com/charmbracelet/bubbles v0.17.1/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= @@ -69,6 +72,8 @@ github.com/ncruces/zenity v0.10.11 h1:5LDM2me4gY7QqnjvR/+O4ZFM+AhM1v1/gFPg6vBCzf github.com/ncruces/zenity v0.10.11/go.mod h1:IX17BvaqNALQ8ACkLdJxfzB48pqWFRt7dVeqqugKH84= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 h1:GranzK4hv1/pqTIhMTXt2X8MmMOuH3hMeUR0o9SP5yc= github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844/go.mod h1:T1TLSfyWVBRXVGzWd0o9BI4kfoO9InEgfQe4NV3mLz8= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -80,6 +85,7 @@ github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2 github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= diff --git a/tssh/expect.go b/tssh/expect.go index 9f3bc7c..879be74 100644 --- a/tssh/expect.go +++ b/tssh/expect.go @@ -387,6 +387,15 @@ func (e *sshExpect) getExpectSender(idx int) *expectSender { return newTextSender(e, text) } + if encTotp := getExConfig(e.alias, fmt.Sprintf("%sExpectSendEncTotp%d", e.pre, idx)); encTotp != "" { + secret, err := decodeSecret(encTotp) + if err != nil { + warning("decode %sExpectSendEncTotp%d [%s] failed: %v", e.pre, idx, encTotp, err) + return nil + } + return newPassSender(e, getTotpCode(secret)) + } + if encOtp := getExConfig(e.alias, fmt.Sprintf("%sExpectSendEncOtp%d", e.pre, idx)); encOtp != "" { command, err := decodeSecret(encOtp) if err != nil { @@ -396,6 +405,10 @@ func (e *sshExpect) getExpectSender(idx int) *expectSender { return newPassSender(e, getOtpCommandOutput(command)) } + if secret := getExConfig(e.alias, fmt.Sprintf("%sExpectSendTotp%d", e.pre, idx)); secret != "" { + return newPassSender(e, getTotpCode(secret)) + } + if command := getExConfig(e.alias, fmt.Sprintf("%sExpectSendOtp%d", e.pre, idx)); command != "" { return newPassSender(e, getOtpCommandOutput(command)) } diff --git a/tssh/login.go b/tssh/login.go index 47c21a9..e286fb2 100644 --- a/tssh/login.go +++ b/tssh/login.go @@ -26,7 +26,6 @@ package tssh import ( "bufio" - "bytes" "crypto/x509" "encoding/hex" "fmt" @@ -564,32 +563,6 @@ func getPasswordAuthMethod(args *sshArgs, host, user string) ssh.AuthMethod { }), 3) } -func getOtpCommandOutput(command string) string { - argv, err := splitCommandLine(command) - if err != nil || len(argv) == 0 { - warning("split otp command failed: %v", err) - return "" - } - if enableDebugLogging { - for i, arg := range argv { - debug("otp command argv[%d] = %s", i, arg) - } - } - cmd := exec.Command(argv[0], argv[1:]...) - var outBuf, errBuf bytes.Buffer - cmd.Stdout = &outBuf - cmd.Stderr = &errBuf - if err := cmd.Run(); err != nil { - if errBuf.Len() > 0 { - warning("exec otp command failed: %v, %s", err, strings.TrimSpace(errBuf.String())) - } else { - warning("exec otp command failed: %v", err) - } - return "" - } - return strings.TrimSpace(outBuf.String()) -} - func readQuestionAnswerConfig(dest string, idx int, question string) string { qhex := hex.EncodeToString([]byte(question)) debug("the hex code for question '%s' is %s", question, qhex) @@ -597,6 +570,12 @@ func readQuestionAnswerConfig(dest string, idx int, question string) string { return answer } + if secret := getSecretConfig(dest, "totp"+qhex); secret != "" { + if answer := getTotpCode(secret); answer != "" { + return answer + } + } + if command := getSecretConfig(dest, "otp"+qhex); command != "" { if answer := getOtpCommandOutput(command); answer != "" { return answer @@ -609,6 +588,14 @@ func readQuestionAnswerConfig(dest string, idx int, question string) string { return answer } + qsecret := fmt.Sprintf("TotpSecret%d", idx) + debug("the totp secret key for question '%s' is %s", question, qsecret) + if secret := getSecretConfig(dest, qsecret); secret != "" { + if answer := getTotpCode(secret); answer != "" { + return answer + } + } + qcmd := fmt.Sprintf("OtpCommand%d", idx) debug("the otp command key for question '%s' is %s", question, qcmd) if command := getSecretConfig(dest, qcmd); command != "" { diff --git a/tssh/otp.go b/tssh/otp.go new file mode 100644 index 0000000..400dec6 --- /dev/null +++ b/tssh/otp.go @@ -0,0 +1,69 @@ +/* +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. +*/ + +package tssh + +import ( + "bytes" + "os/exec" + "strings" + "time" + + "github.com/pquerna/otp/totp" +) + +func getOtpCommandOutput(command string) string { + argv, err := splitCommandLine(command) + if err != nil || len(argv) == 0 { + warning("split otp command failed: %v", err) + return "" + } + if enableDebugLogging { + for i, arg := range argv { + debug("otp command argv[%d] = %s", i, arg) + } + } + cmd := exec.Command(argv[0], argv[1:]...) + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + if errBuf.Len() > 0 { + warning("exec otp command failed: %v, %s", err, strings.TrimSpace(errBuf.String())) + } else { + warning("exec otp command failed: %v", err) + } + return "" + } + return strings.TrimSpace(outBuf.String()) +} + +func getTotpCode(secret string) string { + code, err := totp.GenerateCode(secret, time.Now()) + if err != nil { + warning("generate totp code failed: %v", err) + return "" + } + return code +}