ssh login

ssh 原理

SSH之所以能够保证安全,原因在于它采用了公钥加密

  • 远程主机收到用户的登录请求,把自己的公钥发给用户
  • 用户使用这个公钥,将登录密码加密后,发送回来。
  • 远程主机用自己的私钥,解密登录密码,如果密码正确,就同意用户登录

SSH协议的公钥是没有证书中心(CA)公证的,也就是说,都是自己签发的。

中间人攻击:用户可能收到伪造的公钥,获取用户的登录密码

  • 首次登录用户收到公钥指纹(公钥长度较长(这里采用RSA算法,长达1024位),很难比对,所以对其进行MD5计算,将它变成一个128位的指纹)
  • 让用户决定是否接受这个远程主机的公钥
  • 当远程主机的公钥被接受以后,它就会被保存在文件$HOME/.ssh/known_hosts之中。下次再连接这台主机,系统就会认出它的公钥已经保存在本地了,从而跳过警告部分,直接提示输入密码。

应用修改

所有修改记得

重新启动 sshd。

1
2
3
$ sudo systemctl restart sshd
# 或者
$ sudo service sshd restart

密码登录

修改配置

1
2
3
4
vim /etc/ssh/sshd_config

passwordAuthentication yes

修改密码

passwd user-name

修改端口

1
2
3
4
vim /etc/ssh/sshd_config

Port xxx

密钥登录/密钥+密码登录

本地

1
2
3
4
5
6
# 生成密钥对 -f file_path
ssh-keygen -b 4096 [-f server] -t rsa
# 公钥传送到远程主机host
ssh-copy-id -i server.pub user_name@host

ssh -i 私钥路径 user_name@host

ssh-keygen

  • -b参数指定密钥的二进制位数。这个参数值越大,密钥就越不容易破解,但是加密解密的计算开销也会加大。

    一般来说,-b至少应该是1024,更安全一些可以设为2048或者更高。

  • -f参数指定生成的私钥文件。

  • -N参数用于指定私钥的密码(passphrase)。

  • -p参数用于重新指定私钥的密码(passphrase)。它与-N的不同之处在于,新密码不在命令中指定,而是执行后再输入。ssh 先要求输入旧密码,然后要求输入两遍新密码。

  • -t参数用于指定生成密钥的加密算法,一般为dsa或`rsa

如果生成密钥对时输入密码,登录时候也要求密码

host

1
2
3
4
sudo vim /etc/ssh/sshd_config

#PasswordAuthentication yes
PasswordAuthentication no

authorized_keys

远程主机将用户的公钥,保存在登录后的用户主目录的$HOME/.ssh/authorized_keys(权限644)文件中。公钥就是一段字符串,只要把它追加在authorized_keys文件的末尾就行了。

等效

1
2
# 公钥传送到远程主机host
ssh-copy-id -i server.pub user_name@host

使用ssh-copy-id命令之前,务必保证authorized_keys文件的末尾是换行符(假设该文件已经存在)。

证书登录

配置非常麻烦而且只支持域名但是

  • 密码登录不安全
  • 密钥登录需要服务器保存用户的公钥,也需要用户保存服务器公钥的指纹。这对于多用户、多服务器的大型机构很不方便,如果有员工离职,需要将他的公钥从每台服务器删除。

引入了一个证书颁发机构(Certificate1 authority,简称 CA),对信任的服务器颁发服务器证书,对信任的用户颁发用户证书。

登录时,用户和服务器不需要提前知道彼此的公钥,只需要交换各自的证书,验证是否可信即可。

优点

  • 用户和服务器不用交换公钥
  • 证书可以设置到期时间,公钥没有到期时间

流程

准备

  • 用户和服务器都将自己的公钥,发给 CA;

  • CA 使用服务器公钥,生成服务器证书,发给服务器

  • CA 使用用户的公钥,生成用户证书,发给用户。

登录 (验证对方证书,CA可靠)

  • 用户登录服务器时,SSH 自动将用户证书发给服务器
  • 服务器检查用户证书是否有效,以及是否由可信的 CA 颁发
  • SSH 自动将服务器证书发给用户
  • 用户检查服务器证书是否有效,以及是否由信任的 CA 颁发
  • 双方建立连接,服务器允许用户登录

CA 服务器生成密钥对

CA 可以用同一对密码签发用户证书和服务器证书,但是出于安全性和灵活性,最好用不同的密钥分别签发

1
2
3
4
# user_ca user_ca.pub
ssh-keygen -t rsa -b 4096 -f user_ca
# host_ca host_ca.pub
ssh-keygen -t rsa -b 4096 -f host_ca

双方生成自己的密钥对

服务器

1
2
# 一般来说,SSH 服务器(通常是sshd)安装时,已经生成密钥/etc/ssh/ssh_host_rsa_key了。如果没有的话,可以用下面的命令生成
ssh-keygen -f /etc/ssh/ssh_host_rsa_key -b 4096 -t rsa

用户

1
ssh-keygen -f user_key -b 4096 -t rsa

CA服务器签发双方公钥

1
2
3
4
# 签发服务器公钥
ssh-keygen -s host_ca -I host.example.com -h -n host.example.com -V +52w ssh_host_rsa_key.pub
# 签发用户公钥
ssh-keygen -s user_ca -I user@example.com -n user -V +1d user_key.pub
  • -s:指定 CA 签发证书的密钥。
  • -I:身份字符串,可以随便设置,相当于注释,方便区分证书,将来可以使用这个字符串撤销证书。
  • -h:指定该证书是服务器证书,而不是用户证书。
  • -n host.example.com:指定服务器的域名,表示证书仅对该域名有效。如果有多个域名,则使用逗号分隔。用户登录该域名服务器时,SSH 通过证书的这个值,分辨应该使用哪张证书发给用户,用来证明服务器的可信性。
  • -n user:指定用户名,表示证书仅对该用户名有效。如果有多个用户名,使用逗号分隔。用户以该用户名登录服务器时,SSH 通过这个值,分辨应该使用哪张证书,证明自己的身份,发给服务器。
  • -V +52w:指定证书的有效期,这里为52周(一年)。默认情况下,证书是永远有效的。建议使用该参数指定有效期,并且有效期最好短一点,最长不超过52周。
1
2
3
4
# 查看证书的细节
ssh-keygen -L -f xxx-cert.pub
# 证书设置权限
chmod 600 xxx-cert.pub

证书安装

服务器

  • ssh_host_rsa_key-cert.pub 公钥证书

  • user_ca.pub CA签发用户的公钥

放到/etc/ssh/下面

1
2
3
4
vim /etc/ssh/sshd_config

HostCertificate /etc/ssh/ssh_host_rsa_key-cert.pub
TrustedUserCAKeys /etc/ssh/user_ca.pub

客户端

  • user_key-cert.pubuser_key 放在一个目录

  • CA签发服务器证书的公钥host_ca.pub加到客户端的/etc/ssh/ssh_known_hosts文件(全局级别)或者~/.ssh/known_hosts文件(用户级别)。

追加一行,开头为@cert-authority *.example.com,然后将host_ca.pub文件的内容(即公钥)粘贴在后面,大概是下面这个样子。

1
@cert-authority *.example.com ssh-rsa AAAAB3Nz...XNRM1EX2gQ==

上面代码中,*.example.com是域名的模式匹配,表示只要服务器符合该模式的域名,且签发服务器证书的 CA 匹配后面给出的公钥,就都可以信任。如果没有域名限制,这里可以写成*。如果有多个域名模式,可以使用逗号分隔;如果服务器没有域名,可以用主机名(比如host1,host2,host3)或者 IP 地址(比如11.12.13.14,21.22.23.24)。

验证

ssh -i ~/.ssh/user_key user@host.example.com

废除证书

废除证书的操作,分成用户证书的废除和服务器证书的废除两种。

服务器证书的废除,用户需要在known_hosts文件里面,修改或删除对应的@cert-authority命令的那一行。

用户证书的废除,需要在服务器新建一个/etc/ssh/revoked_keys文件,然后在配置文件sshd_config添加一行,内容如下。

1
RevokedKeys /etc/ssh/revoked_keys

revoked_keys文件保存不再信任的用户公钥,由下面的命令生成。

1
$ ssh-keygen -kf /etc/ssh/revoked_keys -z 1 ~/.ssh/user1_key.pub

上面命令中,-z参数用来指定用户公钥保存在revoked_keys文件的哪一行,这个例子是保存在第1行。

如果以后需要废除其他的用户公钥,可以用下面的命令保存在第2行。

1
$ ssh-keygen -ukf /etc/ssh/revoked_keys -z 2 ~/.ssh/user2_key.pub

模拟SSH登录代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
package utils

import (
	"errors"
	"fmt"
	"io/ioutil"
	"os"
	"path/filepath"
	"sync"
	"time"

	"github.com/keakon/golog/log"
	"golang.org/x/crypto/ssh"
)

const (
	homeEnv        = "HOME"
	homeDriveEnv   = "HOMEDRIVE"
	homePathEnv    = "HOMEPATH"
	userProfileEnv = "USERPROFILE"

	dialTimeOut = 3 * time.Second
)

// UserHome returns user's home dir.
func UserHome() string {
	if home := os.Getenv(homeEnv); home != "" {
		return home
	}
	homeDrive := os.Getenv(homeDriveEnv)
	homePath := os.Getenv(homePathEnv)
	if homeDrive != "" && homePath != "" {
		return homeDrive + homePath
	}
	return os.Getenv(userProfileEnv)
}

type SSHDialer struct {
	Hosts     []string `json:"hosts" binding:"required"`
	UserName  string   `json:"user_name" binding:"required"`
	Port      uint16   `json:"port" binding:"required"`
	PassWD    string   `json:"user_passwd"`
	KeyPath   string   `json:"key_path"`
	KeyPassWD string   `json:"key_passwd"`
	CertPath  string   `json:"cert_path"`
}

func sshReadPath(rawPath string) (bool, []byte) {
	path := rawPath
	if rawPath[:2] == "~/" {
		path = filepath.Join(UserHome(), rawPath[2:])
		log.Warnf("%s to %s", rawPath, path)
	}
	key, err := ioutil.ReadFile(path)
	if err != nil {
		return false, key
	}
	return true, key
}

func sshPrivateKey(key []byte, passphrase string) (signer ssh.Signer, err error) {
	if len(passphrase) > 0 {
		signer, err = ssh.ParsePrivateKeyWithPassphrase(key, []byte(passphrase))
	} else {
		signer, err = ssh.ParsePrivateKey(key)
	}
	return
}

func sshCertKey(sshCert []byte, privateSigner ssh.Signer) (signer ssh.Signer, err error) {
	key, _, _, _, err := ssh.ParseAuthorizedKey(sshCert)
	if err != nil {
		return
	}

	if _, ok := key.(*ssh.Certificate); !ok {
		return nil, errors.New("unable to cast public key to SSH certificate")
	}
	signer, err = ssh.NewCertSigner(key.(*ssh.Certificate), privateSigner)
	if err != nil {
		return
	}

	return
}

func getSSHConfig(m *SSHDialer) (config *ssh.ClientConfig, err error) {
	config = &ssh.ClientConfig{
		User:            m.UserName,
		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
		Timeout:         dialTimeOut,
	}
	if len(m.KeyPath) > 0 {
		isExist, key := sshReadPath(m.KeyPath)
		if !isExist {
			return nil, errors.New(fmt.Sprintf("no such private file %s", m.KeyPath))
		}
    // 密钥登录
		signer, err := sshPrivateKey(key, m.KeyPassWD)
		if err != nil {
			return nil, err
		}
		if len(m.CertPath) > 0 {
			isExist, key = sshReadPath(m.CertPath)
			if !isExist {
				return nil, errors.New(fmt.Sprintf("no such certificate file %s", m.CertPath))
			}
			// 证书登录 cert 需要私钥解析
			signer, err = sshCertKey(key, signer)
			if err != nil {
				return nil, err
			}
		}
		config.Auth = append(config.Auth, ssh.PublicKeys(signer))
	}

	if len(m.PassWD) > 0 {
    // 密码登录
		config.Auth = append(config.Auth, ssh.Password(m.PassWD))
	}

	return config, nil
}

func ValidSSHConnect(m *SSHDialer) (errResults map[string]string, err error) {
	errResults = map[string]string{}
	sshConfig, err := getSSHConfig(m)
	if err != nil {
		return
	}

	var wg sync.WaitGroup
	for _, host := range m.Hosts {
		wg.Add(1)

		go func(host string) {
			defer wg.Done()
			addr := fmt.Sprintf("%v:%v", host, m.Port)
			client, err := ssh.Dial("tcp", addr, sshConfig)
			if err != nil {
				errResults[host] = err.Error()
			} else {
				defer client.Close()
			}
		}(host)
	}
	wg.Wait()

	return
}