Skip to content

Commit

Permalink
built-in support totp
Browse files Browse the repository at this point in the history
  • Loading branch information
lonnywong committed Feb 4, 2024
1 parent 50a26ce commit 2f22aaf
Show file tree
Hide file tree
Showing 7 changed files with 167 additions and 32 deletions.
31 changes: 30 additions & 1 deletion README.cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

- 在匹配到指定输出时,执行指定的命令获取动态密码,然后自动输入,用法如下:

```
Expand Down Expand Up @@ -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 编码进行配置):

```
Expand All @@ -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 得到的密文串
Expand Down
31 changes: 30 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
```
Expand Down Expand Up @@ -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):
```
Expand All @@ -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`
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
10 changes: 8 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
Expand Down
13 changes: 13 additions & 0 deletions tssh/expect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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))
}
Expand Down
41 changes: 14 additions & 27 deletions tssh/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ package tssh

import (
"bufio"
"bytes"
"crypto/x509"
"encoding/hex"
"fmt"
Expand Down Expand Up @@ -564,39 +563,19 @@ 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)
if answer := getSecretConfig(dest, qhex); answer != "" {
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
Expand All @@ -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 != "" {
Expand Down
69 changes: 69 additions & 0 deletions tssh/otp.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 2f22aaf

Please sign in to comment.