Skip to content

Commit 7413539

Browse files
authored
main, sshd: Refactor authentication, add IP throttling, improve passphrase auth
* Move password authentication handling into sshd/auth (fixes #394). Password authentication is now completely handeled in Auth. The normal keyboard-interactive handler checks if passwords are supported and asks for them, removing the need to override the callbacks. Brute force throttling is removed; I'd like to base it on IP address banning, which requires changes to the checks. I'm not sure, but I think timing attacks against the password are fixed: - The hashing of the real password happens only at startup. - The hashing of a provided password is something an attacker can do themselves; It doesn't leak anything about the real password. - The hash comparison is constant-time. * refactor checks, IP-ban incorrect passphrases, renames - s/assword/assphrase/, typo fixes - bans are checked separately from public keys - an incorrect passphrase results in a one-minute IP ban - whitelists no longer override bans (i.e. you can get banned if you're whitelisted) * (hopefully) final changes
1 parent c3b589b commit 7413539

File tree

5 files changed

+148
-69
lines changed

5 files changed

+148
-69
lines changed

auth.go

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package sshchat
22

33
import (
4+
"crypto/sha256"
5+
"crypto/subtle"
46
"encoding/csv"
57
"errors"
68
"fmt"
@@ -17,9 +19,12 @@ import (
1719
// when whitelisting is enabled.
1820
var ErrNotWhitelisted = errors.New("not whitelisted")
1921

20-
// ErrBanned is the error returned when a key is checked that is banned.
22+
// ErrBanned is the error returned when a client is banned.
2123
var ErrBanned = errors.New("banned")
2224

25+
// ErrIncorrectPassphrase is the error returned when a provided passphrase is incorrect.
26+
var ErrIncorrectPassphrase = errors.New("incorrect passphrase")
27+
2328
// newAuthKey returns string from an ssh.PublicKey used to index the key in our lookup.
2429
func newAuthKey(key ssh.PublicKey) string {
2530
if key == nil {
@@ -43,12 +48,14 @@ func newAuthAddr(addr net.Addr) string {
4348
}
4449

4550
// Auth stores lookups for bans, whitelists, and ops. It implements the sshd.Auth interface.
51+
// If the contained passphrase is not empty, it complements a whitelist.
4652
type Auth struct {
47-
bannedAddr *set.Set
48-
bannedClient *set.Set
49-
banned *set.Set
50-
whitelist *set.Set
51-
ops *set.Set
53+
passphraseHash []byte
54+
bannedAddr *set.Set
55+
bannedClient *set.Set
56+
banned *set.Set
57+
whitelist *set.Set
58+
ops *set.Set
5259
}
5360

5461
// NewAuth creates a new empty Auth.
@@ -62,23 +69,30 @@ func NewAuth() *Auth {
6269
}
6370
}
6471

72+
// SetPassphrase enables passphrase authentication with the given passphrase.
73+
// If an empty passphrase is given, disable passphrase authentication.
74+
func (a *Auth) SetPassphrase(passphrase string) {
75+
if passphrase == "" {
76+
a.passphraseHash = nil
77+
} else {
78+
hashArray := sha256.Sum256([]byte(passphrase))
79+
a.passphraseHash = hashArray[:]
80+
}
81+
}
82+
6583
// AllowAnonymous determines if anonymous users are permitted.
6684
func (a *Auth) AllowAnonymous() bool {
67-
return a.whitelist.Len() == 0
85+
return a.whitelist.Len() == 0 && a.passphraseHash == nil
6886
}
6987

70-
// Check determines if a pubkey fingerprint is permitted.
71-
func (a *Auth) Check(addr net.Addr, key ssh.PublicKey, clientVersion string) error {
72-
authkey := newAuthKey(key)
88+
// AcceptPassphrase determines if passphrase authentication is accepted.
89+
func (a *Auth) AcceptPassphrase() bool {
90+
return a.passphraseHash != nil
91+
}
7392

74-
if a.whitelist.Len() != 0 {
75-
// Only check whitelist if there is something in it, otherwise it's disabled.
76-
whitelisted := a.whitelist.In(authkey)
77-
if !whitelisted {
78-
return ErrNotWhitelisted
79-
}
80-
return nil
81-
}
93+
// CheckBans checks IP, key and client bans.
94+
func (a *Auth) CheckBans(addr net.Addr, key ssh.PublicKey, clientVersion string) error {
95+
authkey := newAuthKey(key)
8296

8397
var banned bool
8498
if authkey != "" {
@@ -98,6 +112,29 @@ func (a *Auth) Check(addr net.Addr, key ssh.PublicKey, clientVersion string) err
98112
return nil
99113
}
100114

115+
// CheckPubkey determines if a pubkey fingerprint is permitted.
116+
func (a *Auth) CheckPublicKey(key ssh.PublicKey) error {
117+
authkey := newAuthKey(key)
118+
whitelisted := a.whitelist.In(authkey)
119+
if a.AllowAnonymous() || whitelisted {
120+
return nil
121+
} else {
122+
return ErrNotWhitelisted
123+
}
124+
}
125+
126+
// CheckPassphrase determines if a passphrase is permitted.
127+
func (a *Auth) CheckPassphrase(passphrase string) error {
128+
if !a.AcceptPassphrase() {
129+
return errors.New("passphrases not accepted") // this should never happen
130+
}
131+
passedPassphraseHash := sha256.Sum256([]byte(passphrase))
132+
if subtle.ConstantTimeCompare(passedPassphraseHash[:], a.passphraseHash) == 0 {
133+
return ErrIncorrectPassphrase
134+
}
135+
return nil
136+
}
137+
101138
// Op sets a public key as a known operator.
102139
func (a *Auth) Op(key ssh.PublicKey, d time.Duration) {
103140
if key == nil {

auth_test.go

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func TestAuthWhitelist(t *testing.T) {
2828
}
2929

3030
auth := NewAuth()
31-
err = auth.Check(nil, key, "")
31+
err = auth.CheckPublicKey(key)
3232
if err != nil {
3333
t.Error("Failed to permit in default state:", err)
3434
}
@@ -44,7 +44,7 @@ func TestAuthWhitelist(t *testing.T) {
4444
t.Error("Clone key does not match.")
4545
}
4646

47-
err = auth.Check(nil, keyClone, "")
47+
err = auth.CheckPublicKey(keyClone)
4848
if err != nil {
4949
t.Error("Failed to permit whitelisted:", err)
5050
}
@@ -54,8 +54,42 @@ func TestAuthWhitelist(t *testing.T) {
5454
t.Fatal(err)
5555
}
5656

57-
err = auth.Check(nil, key2, "")
57+
err = auth.CheckPublicKey(key2)
5858
if err == nil {
5959
t.Error("Failed to restrict not whitelisted:", err)
6060
}
6161
}
62+
63+
func TestAuthPassphrases(t *testing.T) {
64+
auth := NewAuth()
65+
66+
if auth.AcceptPassphrase() {
67+
t.Error("Doesn't known it won't accept passphrases.")
68+
}
69+
auth.SetPassphrase("")
70+
if auth.AcceptPassphrase() {
71+
t.Error("Doesn't known it won't accept passphrases.")
72+
}
73+
74+
err := auth.CheckPassphrase("Pa$$w0rd")
75+
if err == nil {
76+
t.Error("Failed to deny without passphrase:", err)
77+
}
78+
79+
auth.SetPassphrase("Pa$$w0rd")
80+
81+
err = auth.CheckPassphrase("Pa$$w0rd")
82+
if err != nil {
83+
t.Error("Failed to allow vaild passphrase:", err)
84+
}
85+
86+
err = auth.CheckPassphrase("something else")
87+
if err == nil {
88+
t.Error("Failed to restrict wrong passphrase:", err)
89+
}
90+
91+
auth.SetPassphrase("")
92+
if auth.AcceptPassphrase() {
93+
t.Error("Didn't clear passphrase.")
94+
}
95+
}

cmd/ssh-chat/cmd.go

Lines changed: 4 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,13 @@ package main
22

33
import (
44
"bufio"
5-
"errors"
65
"fmt"
76
"io/ioutil"
87
"net/http"
98
"os"
109
"os/signal"
1110
"os/user"
1211
"strings"
13-
"time"
1412

1513
"github.com/alexcesaro/log"
1614
"github.com/alexcesaro/log/golog"
@@ -123,44 +121,6 @@ func main() {
123121
config.ServerVersion = "SSH-2.0-Go ssh-chat"
124122
// FIXME: Should we be using config.NoClientAuth = true by default?
125123

126-
if options.Passphrase != "" {
127-
if options.Whitelist != "" {
128-
logger.Warning("Passphrase is disabled while whitelist is enabled.")
129-
}
130-
if config.KeyboardInteractiveCallback != nil {
131-
fail(1, "Passphrase authentication conflicts with existing KeyboardInteractive setup.") // This should not happen
132-
}
133-
134-
// We use KeyboardInteractiveCallback instead of PasswordCallback to
135-
// avoid preventing the client from including a pubkey in the user
136-
// identification.
137-
config.KeyboardInteractiveCallback = func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
138-
answers, err := challenge("", "", []string{"Passphrase required to connect: "}, []bool{true})
139-
if err != nil {
140-
return nil, err
141-
}
142-
if len(answers) == 1 && answers[0] == options.Passphrase {
143-
// Success
144-
return nil, nil
145-
}
146-
// It's not gonna do much but may as well throttle brute force attempts a little
147-
time.Sleep(2 * time.Second)
148-
149-
return nil, errors.New("incorrect passphrase")
150-
}
151-
152-
// We also need to override the PublicKeyCallback to prevent rando pubkeys from bypassing
153-
cb := config.PublicKeyCallback
154-
config.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
155-
perms, err := cb(conn, key)
156-
if err == nil {
157-
err = errors.New("passphrase authentication required")
158-
}
159-
return perms, err
160-
}
161-
162-
}
163-
164124
s, err := sshd.ListenSSH(options.Bind, config)
165125
if err != nil {
166126
fail(4, "Failed to listen on socket: %v\n", err)
@@ -174,6 +134,10 @@ func main() {
174134
host.SetTheme(message.Themes[0])
175135
host.Version = Version
176136

137+
if options.Passphrase != "" {
138+
auth.SetPassphrase(options.Passphrase)
139+
}
140+
177141
err = fromFile(options.Admin, func(line []byte) error {
178142
key, _, _, _, err := ssh.ParseAuthorizedKey(line)
179143
if err != nil {

sshd/auth.go

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,26 @@ import (
55
"encoding/base64"
66
"errors"
77
"net"
8+
"time"
89

910
"github.com/shazow/ssh-chat/internal/sanitize"
1011
"golang.org/x/crypto/ssh"
1112
)
1213

13-
// Auth is used to authenticate connections based on public keys.
14+
// Auth is used to authenticate connections.
1415
type Auth interface {
1516
// Whether to allow connections without a public key.
1617
AllowAnonymous() bool
17-
// Given address and public key and client agent string, returns nil if the connection should be allowed.
18-
Check(net.Addr, ssh.PublicKey, string) error
18+
// If passphrase authentication is accepted
19+
AcceptPassphrase() bool
20+
// Given address and public key and client agent string, returns nil if the connection is not banned.
21+
CheckBans(net.Addr, ssh.PublicKey, string) error
22+
// Given a public key, returns nil if the connection should be allowed.
23+
CheckPublicKey(ssh.PublicKey) error
24+
// Given a passphrase, returns nil if the connection should be allowed.
25+
CheckPassphrase(string) error
26+
// BanAddr bans an IP address for the specified amount of time.
27+
BanAddr(net.Addr, time.Duration)
1928
}
2029

2130
// MakeAuth makes an ssh.ServerConfig which performs authentication against an Auth implementation.
@@ -25,7 +34,11 @@ func MakeAuth(auth Auth) *ssh.ServerConfig {
2534
NoClientAuth: false,
2635
// Auth-related things should be constant-time to avoid timing attacks.
2736
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
28-
err := auth.Check(conn.RemoteAddr(), key, sanitize.Data(string(conn.ClientVersion()), 64))
37+
err := auth.CheckBans(conn.RemoteAddr(), key, sanitize.Data(string(conn.ClientVersion()), 64))
38+
if err != nil {
39+
return nil, err
40+
}
41+
err = auth.CheckPublicKey(key)
2942
if err != nil {
3043
return nil, err
3144
}
@@ -34,11 +47,31 @@ func MakeAuth(auth Auth) *ssh.ServerConfig {
3447
}}
3548
return perm, nil
3649
},
50+
51+
// We use KeyboardInteractiveCallback instead of PasswordCallback to
52+
// avoid preventing the client from including a pubkey in the user
53+
// identification.
3754
KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
38-
if !auth.AllowAnonymous() {
39-
return nil, errors.New("public key authentication required")
55+
err := auth.CheckBans(conn.RemoteAddr(), nil, sanitize.Data(string(conn.ClientVersion()), 64))
56+
if err != nil {
57+
return nil, err
58+
}
59+
if auth.AcceptPassphrase() {
60+
var answers []string
61+
answers, err = challenge("", "", []string{"Passphrase required to connect: "}, []bool{true})
62+
if err == nil {
63+
if len(answers) != 1 {
64+
err = errors.New("didn't get passphrase")
65+
} else {
66+
err = auth.CheckPassphrase(answers[0])
67+
if err != nil {
68+
auth.BanAddr(conn.RemoteAddr(), time.Second*2)
69+
}
70+
}
71+
}
72+
} else if !auth.AllowAnonymous() {
73+
err = errors.New("public key authentication required")
4074
}
41-
err := auth.Check(conn.RemoteAddr(), nil, sanitize.Data(string(conn.ClientVersion()), 64))
4275
return nil, err
4376
},
4477
}

sshd/client_test.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"net"
66
"testing"
7+
"time"
78

89
"golang.org/x/crypto/ssh"
910
)
@@ -15,9 +16,19 @@ type RejectAuth struct{}
1516
func (a RejectAuth) AllowAnonymous() bool {
1617
return false
1718
}
18-
func (a RejectAuth) Check(net.Addr, ssh.PublicKey, string) error {
19+
func (a RejectAuth) AcceptPassphrase() bool {
20+
return false
21+
}
22+
func (a RejectAuth) CheckBans(addr net.Addr, key ssh.PublicKey, clientVersion string) error {
23+
return errRejectAuth
24+
}
25+
func (a RejectAuth) CheckPublicKey(ssh.PublicKey) error {
26+
return errRejectAuth
27+
}
28+
func (a RejectAuth) CheckPassphrase(string) error {
1929
return errRejectAuth
2030
}
31+
func (a RejectAuth) BanAddr(net.Addr, time.Duration) {}
2132

2233
func TestClientReject(t *testing.T) {
2334
signer, err := NewRandomSigner(512)

0 commit comments

Comments
 (0)