Use authorized_keys-style public keys rather than fingerprints.

Tests for whitelisting.
This commit is contained in:
Andrey Petrov 2015-01-10 12:44:06 -08:00
parent 7a39898b36
commit d8d5deac1c
10 changed files with 310 additions and 94 deletions

61
auth.go
View File

@ -5,24 +5,39 @@ import (
"sync" "sync"
"github.com/shazow/ssh-chat/sshd" "github.com/shazow/ssh-chat/sshd"
"golang.org/x/crypto/ssh"
) )
// The error returned a key is checked that is not whitelisted, with whitelisting required.
var ErrNotWhitelisted = errors.New("not whitelisted")
// The error returned a key is checked that is banned.
var ErrBanned = errors.New("banned")
// AuthKey is the type that our lookups are keyed against.
type AuthKey string
// NewAuthKey returns an AuthKey from an ssh.PublicKey.
func NewAuthKey(key ssh.PublicKey) AuthKey {
// FIXME: Is there a way to index pubkeys without marshal'ing them into strings?
return AuthKey(string(key.Marshal()))
}
// Auth stores fingerprint lookups // Auth stores fingerprint lookups
type Auth struct { type Auth struct {
whitelist map[string]struct{}
banned map[string]struct{}
ops map[string]struct{}
sshd.Auth sshd.Auth
sync.RWMutex sync.RWMutex
whitelist map[AuthKey]struct{}
banned map[AuthKey]struct{}
ops map[AuthKey]struct{}
} }
// NewAuth creates a new default Auth. // NewAuth creates a new default Auth.
func NewAuth() Auth { func NewAuth() *Auth {
return Auth{ return &Auth{
whitelist: make(map[string]struct{}), whitelist: make(map[AuthKey]struct{}),
banned: make(map[string]struct{}), banned: make(map[AuthKey]struct{}),
ops: make(map[string]struct{}), ops: make(map[AuthKey]struct{}),
} }
} }
@ -35,43 +50,49 @@ func (a Auth) AllowAnonymous() bool {
} }
// Check determines if a pubkey fingerprint is permitted. // Check determines if a pubkey fingerprint is permitted.
func (a Auth) Check(fingerprint string) (bool, error) { func (a Auth) Check(key ssh.PublicKey) (bool, error) {
authkey := NewAuthKey(key)
a.RLock() a.RLock()
defer a.RUnlock() defer a.RUnlock()
if len(a.whitelist) > 0 { if len(a.whitelist) > 0 {
// Only check whitelist if there is something in it, otherwise it's disabled. // Only check whitelist if there is something in it, otherwise it's disabled.
_, whitelisted := a.whitelist[fingerprint]
_, whitelisted := a.whitelist[authkey]
if !whitelisted { if !whitelisted {
return false, errors.New("not whitelisted") return false, ErrNotWhitelisted
} }
} }
_, banned := a.banned[fingerprint] _, banned := a.banned[authkey]
if banned { if banned {
return false, errors.New("banned") return false, ErrBanned
} }
return true, nil return true, nil
} }
// Op will set a fingerprint as a known operator. // Op will set a fingerprint as a known operator.
func (a *Auth) Op(fingerprint string) { func (a *Auth) Op(key ssh.PublicKey) {
authkey := NewAuthKey(key)
a.Lock() a.Lock()
a.ops[fingerprint] = struct{}{} a.ops[authkey] = struct{}{}
a.Unlock() a.Unlock()
} }
// Whitelist will set a fingerprint as a whitelisted user. // Whitelist will set a fingerprint as a whitelisted user.
func (a *Auth) Whitelist(fingerprint string) { func (a *Auth) Whitelist(key ssh.PublicKey) {
authkey := NewAuthKey(key)
a.Lock() a.Lock()
a.whitelist[fingerprint] = struct{}{} a.whitelist[authkey] = struct{}{}
a.Unlock() a.Unlock()
} }
// Ban will set a fingerprint as banned. // Ban will set a fingerprint as banned.
func (a *Auth) Ban(fingerprint string) { func (a *Auth) Ban(key ssh.PublicKey) {
authkey := NewAuthKey(key)
a.Lock() a.Lock()
a.banned[fingerprint] = struct{}{} a.banned[authkey] = struct{}{}
a.Unlock() a.Unlock()
} }

62
auth_test.go Normal file
View File

@ -0,0 +1,62 @@
package main
import (
"crypto/rand"
"crypto/rsa"
"testing"
"golang.org/x/crypto/ssh"
)
func NewRandomPublicKey(bits int) (ssh.PublicKey, error) {
key, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil {
return nil, err
}
return ssh.NewPublicKey(key.Public())
}
func ClonePublicKey(key ssh.PublicKey) (ssh.PublicKey, error) {
return ssh.ParsePublicKey(key.Marshal())
}
func TestAuthWhitelist(t *testing.T) {
key, err := NewRandomPublicKey(512)
if err != nil {
t.Fatal(err)
}
auth := NewAuth()
ok, err := auth.Check(key)
if !ok || err != nil {
t.Error("Failed to permit in default state:", err)
}
auth.Whitelist(key)
key_clone, err := ClonePublicKey(key)
if err != nil {
t.Fatal(err)
}
if string(key_clone.Marshal()) != string(key.Marshal()) {
t.Error("Clone key does not match.")
}
ok, err = auth.Check(key_clone)
if !ok || err != nil {
t.Error("Failed to permit whitelisted:", err)
}
key2, err := NewRandomPublicKey(512)
if err != nil {
t.Fatal(err)
}
ok, err = auth.Check(key2)
if ok || err == nil {
t.Error("Failed to restrict not whitelisted:", err)
}
}

101
cmd.go
View File

@ -2,8 +2,6 @@ package main
import ( import (
"bufio" "bufio"
"crypto/x509"
"encoding/pem"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
@ -16,7 +14,6 @@ import (
"github.com/alexcesaro/log/golog" "github.com/alexcesaro/log/golog"
"github.com/jessevdk/go-flags" "github.com/jessevdk/go-flags"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/terminal"
"github.com/shazow/ssh-chat/chat" "github.com/shazow/ssh-chat/chat"
"github.com/shazow/ssh-chat/sshd" "github.com/shazow/ssh-chat/sshd"
@ -25,13 +22,13 @@ import _ "net/http/pprof"
// Options contains the flag options // Options contains the flag options
type Options struct { type Options struct {
Verbose []bool `short:"v" long:"verbose" description:"Show verbose logging."` Verbose []bool `short:"v" long:"verbose" description:"Show verbose logging."`
Identity string `short:"i" long:"identity" description:"Private key to identify server with." default:"~/.ssh/id_rsa"` Identity string `short:"i" long:"identity" description:"Private key to identify server with." default:"~/.ssh/id_rsa"`
Bind string `long:"bind" description:"Host and port to listen on." default:"0.0.0.0:2022"` Bind string `long:"bind" description:"Host and port to listen on." default:"0.0.0.0:2022"`
Admin []string `long:"admin" description:"Fingerprint of pubkey to mark as admin."` Admin string `long:"admin" description:"File of public keys who are admins."`
Whitelist string `long:"whitelist" description:"Optional file of pubkey fingerprints who are allowed to connect."` Whitelist string `long:"whitelist" description:"Optional file of public keys who are allowed to connect."`
Motd string `long:"motd" description:"Optional Message of the Day file."` Motd string `long:"motd" description:"Optional Message of the Day file."`
Pprof int `long:"pprof" description:"Enable pprof http server for profiling."` Pprof int `long:"pprof" description:"Enable pprof http server for profiling."`
} }
var logLevels = []log.Level{ var logLevels = []log.Level{
@ -83,7 +80,7 @@ func main() {
} }
} }
privateKey, err := readPrivateKey(privateKeyPath) privateKey, err := ReadPrivateKey(privateKeyPath)
if err != nil { if err != nil {
logger.Errorf("Couldn't read private key: %v", err) logger.Errorf("Couldn't read private key: %v", err)
os.Exit(2) os.Exit(2)
@ -109,25 +106,35 @@ func main() {
fmt.Printf("Listening for connections on %v\n", s.Addr().String()) fmt.Printf("Listening for connections on %v\n", s.Addr().String())
host := NewHost(s) host := NewHost(s)
host.auth = &auth host.auth = auth
host.theme = &chat.Themes[0] host.theme = &chat.Themes[0]
for _, fingerprint := range options.Admin { err = fromFile(options.Admin, func(line []byte) error {
auth.Op(fingerprint) key, _, _, _, err := ssh.ParseAuthorizedKey(line)
if err != nil {
return err
}
auth.Op(key)
logger.Debugf("Added admin: %s", line)
return nil
})
if err != nil {
logger.Errorf("Failed to load admins: %v", err)
os.Exit(5)
} }
if options.Whitelist != "" { err = fromFile(options.Whitelist, func(line []byte) error {
file, err := os.Open(options.Whitelist) key, _, _, _, err := ssh.ParseAuthorizedKey(line)
if err != nil { if err != nil {
logger.Errorf("Could not open whitelist file") return err
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
auth.Whitelist(scanner.Text())
} }
auth.Whitelist(key)
logger.Debugf("Whitelisted: %s", line)
return nil
})
if err != nil {
logger.Errorf("Failed to load whitelist: %v", err)
os.Exit(5)
} }
if options.Motd != "" { if options.Motd != "" {
@ -154,42 +161,24 @@ func main() {
os.Exit(0) os.Exit(0)
} }
// readPrivateKey attempts to read your private key and possibly decrypt it if it func fromFile(path string, handler func(line []byte) error) error {
// requires a passphrase. if path == "" {
// This function will prompt for a passphrase on STDIN if the environment variable (`IDENTITY_PASSPHRASE`), // Skip
// is not set. return nil
func readPrivateKey(privateKeyPath string) ([]byte, error) { }
privateKey, err := ioutil.ReadFile(privateKeyPath)
file, err := os.Open(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load identity: %v", err) return err
} }
defer file.Close()
block, rest := pem.Decode(privateKey) scanner := bufio.NewScanner(file)
if len(rest) > 0 { for scanner.Scan() {
return nil, fmt.Errorf("extra data when decoding private key") err := handler(scanner.Bytes())
}
if !x509.IsEncryptedPEMBlock(block) {
return privateKey, nil
}
passphrase := []byte(os.Getenv("IDENTITY_PASSPHRASE"))
if len(passphrase) == 0 {
fmt.Printf("Enter passphrase: ")
passphrase, err = terminal.ReadPassword(int(os.Stdin.Fd()))
if err != nil { if err != nil {
return nil, fmt.Errorf("couldn't read passphrase: %v", err) return err
} }
fmt.Println()
} }
der, err := x509.DecryptPEMBlock(block, passphrase) return nil
if err != nil {
return nil, fmt.Errorf("decrypt failed: %v", err)
}
privateKey = pem.EncodeToMemory(&pem.Block{
Type: block.Type,
Bytes: der,
})
return privateKey, nil
} }

View File

@ -2,12 +2,15 @@ package main
import ( import (
"bufio" "bufio"
"crypto/rand"
"crypto/rsa"
"io" "io"
"strings" "strings"
"testing" "testing"
"github.com/shazow/ssh-chat/chat" "github.com/shazow/ssh-chat/chat"
"github.com/shazow/ssh-chat/sshd" "github.com/shazow/ssh-chat/sshd"
"golang.org/x/crypto/ssh"
) )
func stripPrompt(s string) string { func stripPrompt(s string) string {
@ -39,7 +42,7 @@ func TestHostGetPrompt(t *testing.T) {
} }
func TestHostNameCollision(t *testing.T) { func TestHostNameCollision(t *testing.T) {
key, err := sshd.NewRandomKey(512) key, err := sshd.NewRandomSigner(512)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -50,6 +53,7 @@ func TestHostNameCollision(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer s.Close()
host := NewHost(s) host := NewHost(s)
go host.Serve() go host.Serve()
@ -110,5 +114,44 @@ func TestHostNameCollision(t *testing.T) {
} }
<-done <-done
s.Close() }
func TestHostWhitelist(t *testing.T) {
key, err := sshd.NewRandomSigner(512)
if err != nil {
t.Fatal(err)
}
auth := NewAuth()
config := sshd.MakeAuth(auth)
config.AddHostKey(key)
s, err := sshd.ListenSSH(":0", config)
if err != nil {
t.Fatal(err)
}
defer s.Close()
host := NewHost(s)
host.auth = auth
go host.Serve()
target := s.Addr().String()
err = sshd.NewClientSession(target, "foo", func(r io.Reader, w io.WriteCloser) {})
if err != nil {
t.Error(err)
}
clientkey, err := rsa.GenerateKey(rand.Reader, 512)
if err != nil {
t.Fatal(err)
}
clientpubkey, _ := ssh.NewPublicKey(clientkey.Public())
auth.Whitelist(clientpubkey)
err = sshd.NewClientSession(target, "foo", func(r io.Reader, w io.WriteCloser) {})
if err == nil {
t.Error("Failed to block unwhitelisted connection.")
}
} }

49
key.go Normal file
View File

@ -0,0 +1,49 @@
package main
import (
"crypto/x509"
"encoding/pem"
"fmt"
"io/ioutil"
"os"
"code.google.com/p/gopass"
)
// ReadPrivateKey attempts to read your private key and possibly decrypt it if it
// requires a passphrase.
// This function will prompt for a passphrase on STDIN if the environment variable (`IDENTITY_PASSPHRASE`),
// is not set.
func ReadPrivateKey(path string) ([]byte, error) {
privateKey, err := ioutil.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to load identity: %v", err)
}
block, rest := pem.Decode(privateKey)
if len(rest) > 0 {
return nil, fmt.Errorf("extra data when decoding private key")
}
if !x509.IsEncryptedPEMBlock(block) {
return privateKey, nil
}
passphrase := os.Getenv("IDENTITY_PASSPHRASE")
if passphrase == "" {
passphrase, err = gopass.GetPass("Enter passphrase: ")
if err != nil {
return nil, fmt.Errorf("couldn't read passphrase: %v", err)
}
}
der, err := x509.DecryptPEMBlock(block, []byte(passphrase))
if err != nil {
return nil, fmt.Errorf("decrypt failed: %v", err)
}
privateKey = pem.EncodeToMemory(&pem.Block{
Type: block.Type,
Bytes: der,
})
return privateKey, nil
}

View File

@ -1,30 +1,34 @@
package sshd package sshd
import ( import (
"crypto/sha1" "crypto/sha256"
"encoding/base64"
"errors" "errors"
"fmt"
"strings"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
// Auth is used to authenticate connections based on public keys.
type Auth interface { type Auth interface {
// Whether to allow connections without a public key.
AllowAnonymous() bool AllowAnonymous() bool
Check(string) (bool, error) // Given public key, return if the connection should be permitted.
Check(ssh.PublicKey) (bool, error)
} }
// MakeAuth makes an ssh.ServerConfig which performs authentication against an Auth implementation.
func MakeAuth(auth Auth) *ssh.ServerConfig { func MakeAuth(auth Auth) *ssh.ServerConfig {
config := ssh.ServerConfig{ config := ssh.ServerConfig{
NoClientAuth: false, NoClientAuth: false,
// Auth-related things should be constant-time to avoid timing attacks. // Auth-related things should be constant-time to avoid timing attacks.
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
fingerprint := Fingerprint(key) ok, err := auth.Check(key)
ok, err := auth.Check(fingerprint)
if !ok { if !ok {
return nil, err return nil, err
} }
perm := &ssh.Permissions{Extensions: map[string]string{"fingerprint": fingerprint}} perm := &ssh.Permissions{Extensions: map[string]string{
"pubkey": string(ssh.MarshalAuthorizedKey(key)),
}}
return perm, nil return perm, nil
}, },
KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
@ -38,12 +42,16 @@ func MakeAuth(auth Auth) *ssh.ServerConfig {
return &config return &config
} }
// MakeNoAuth makes a simple ssh.ServerConfig which allows all connections.
// Primarily used for testing.
func MakeNoAuth() *ssh.ServerConfig { func MakeNoAuth() *ssh.ServerConfig {
config := ssh.ServerConfig{ config := ssh.ServerConfig{
NoClientAuth: false, NoClientAuth: false,
// Auth-related things should be constant-time to avoid timing attacks. // Auth-related things should be constant-time to avoid timing attacks.
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
perm := &ssh.Permissions{Extensions: map[string]string{"fingerprint": Fingerprint(key)}} perm := &ssh.Permissions{Extensions: map[string]string{
"pubkey": string(ssh.MarshalAuthorizedKey(key)),
}}
return perm, nil return perm, nil
}, },
KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
@ -54,8 +62,9 @@ func MakeNoAuth() *ssh.ServerConfig {
return &config return &config
} }
// Fingerprint performs a SHA256 BASE64 fingerprint of the PublicKey, similar to OpenSSH.
// See: https://anongit.mindrot.org/openssh.git/commit/?id=56d1c83cdd1ac
func Fingerprint(k ssh.PublicKey) string { func Fingerprint(k ssh.PublicKey) string {
hash := sha1.Sum(k.Marshal()) hash := sha256.Sum256(k.Marshal())
r := fmt.Sprintf("% x", hash) return base64.StdEncoding.EncodeToString(hash[:])
return strings.Replace(r, " ", ":", -1)
} }

View File

@ -8,8 +8,8 @@ import (
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
// NewRandomKey generates a random key of a desired bit length. // NewRandomSigner generates a random key of a desired bit length.
func NewRandomKey(bits int) (ssh.Signer, error) { func NewRandomSigner(bits int) (ssh.Signer, error) {
key, err := rsa.GenerateKey(rand.Reader, bits) key, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil { if err != nil {
return nil, err return nil, err

44
sshd/client_test.go Normal file
View File

@ -0,0 +1,44 @@
package sshd
import (
"errors"
"testing"
"golang.org/x/crypto/ssh"
)
var errRejectAuth = errors.New("not welcome here")
type RejectAuth struct{}
func (a RejectAuth) AllowAnonymous() bool {
return false
}
func (a RejectAuth) Check(ssh.PublicKey) (bool, error) {
return false, errRejectAuth
}
func consume(ch <-chan *Terminal) {
for range ch {}
}
func TestClientReject(t *testing.T) {
signer, err := NewRandomSigner(512)
config := MakeAuth(RejectAuth{})
config.AddHostKey(signer)
s, err := ListenSSH(":0", config)
if err != nil {
t.Fatal(err)
}
defer s.Close()
go consume(s.ServeTerminal())
conn, err := ssh.Dial("tcp", s.Addr().String(), NewClientConfig("foo"))
if err == nil {
defer conn.Close()
t.Error("Failed to reject conncetion")
}
t.Log(err)
}

View File

@ -29,6 +29,7 @@ func (l *SSHListener) handleConn(conn net.Conn) (*Terminal, error) {
return nil, err return nil, err
} }
// FIXME: Disconnect if too many faulty requests? (Avoid DoS.)
go ssh.DiscardRequests(requests) go ssh.DiscardRequests(requests)
return NewSession(sshConn, channels) return NewSession(sshConn, channels)
} }

View File

@ -6,8 +6,6 @@ import (
"testing" "testing"
) )
// TODO: Move some of these into their own package?
func TestServerInit(t *testing.T) { func TestServerInit(t *testing.T) {
config := MakeNoAuth() config := MakeNoAuth()
s, err := ListenSSH(":badport", config) s, err := ListenSSH(":badport", config)
@ -27,7 +25,7 @@ func TestServerInit(t *testing.T) {
} }
func TestServeTerminals(t *testing.T) { func TestServeTerminals(t *testing.T) {
signer, err := NewRandomKey(512) signer, err := NewRandomSigner(512)
config := MakeNoAuth() config := MakeNoAuth()
config.AddHostKey(signer) config.AddHostKey(signer)