Просмотр исходного кода

Add server-side SSH KEX randomization

- Enabled for "SSH" tunnel protocol. Client
  will check server KEX in advance and adjust
  its KEX to ensure negotiation succeeds.

- Tweak KEX compression randomization.

- Refactored NoEncryptThenMACHash logic.

- Add common/crypto/ssh tests to TravisCI,
  including new KEX randomization test.
Rod Hynes 7 лет назад
Родитель
Сommit
158caea562

+ 2 - 0
.travis.yml

@@ -12,6 +12,7 @@ script:
 - cd psiphon
 - go test -race -v ./common
 - go test -race -v ./common/accesscontrol
+- go test -race -v ./common/crypto/ssh
 - go test -race -v ./common/fragmentor
 - go test -race -v ./common/obfuscator
 - go test -race -v ./common/osl
@@ -33,6 +34,7 @@ script:
 - go test -race -v
 - go test -v -covermode=count -coverprofile=common.coverprofile ./common
 - go test -v -covermode=count -coverprofile=accesscontrol.coverprofile ./common/accesscontrol
+- go test -v -covermode=count -coverprofile=ssh.coverprofile ./common/crypto/ssh
 - go test -v -covermode=count -coverprofile=fragmentor.coverprofile ./common/fragmentor
 - go test -v -covermode=count -coverprofile=obfuscator.coverprofile ./common/obfuscator
 - go test -v -covermode=count -coverprofile=osl.coverprofile ./common/osl

+ 9 - 0
psiphon/common/crypto/ssh/common.go

@@ -219,8 +219,17 @@ type Config struct {
 	MACs []string
 
 	// [Psiphon]
+
+	// NoEncryptThenMACHash is used to disable Encrypt-then-MAC hash
+	// algorithms.
+	NoEncryptThenMACHash bool
+
 	// KEXPRNGSeed is used for KEX randomization and replay.
 	KEXPRNGSeed *prng.Seed
+
+	// PeerKEXPRNGSeed is used to predict KEX randomization and make
+	// adjustments to ensure negotiation succeeds.
+	PeerKEXPRNGSeed *prng.Seed
 }
 
 // SetDefaults sets sensible values for unset fields in config. This is

+ 108 - 31
psiphon/common/crypto/ssh/handshake.go

@@ -469,24 +469,59 @@ func (t *handshakeTransport) sendKexInit() error {
 
 	// [Psiphon]
 	//
-	// Randomize KEX. The offered algorithms are shuffled and
-	// truncated (longer lists are selected with higher
-	// probability).
+	// When KEXPRNGSeed is specified, randomize the KEX. The offered
+	// algorithms are shuffled and truncated. Longer lists are selected with
+	// higher probability.
 	//
-	// As the client and server have the same set of algorithms,
-	// almost any combination is expected to be workable.
+	// When PeerKEXPRNGSeed is specified, the peer is expected to randomize
+	// its KEX using the specified seed; deterministically adjust own
+	// randomized KEX to ensure negotiation succeeds.
 	//
-	// The compression algorithm is not actually supported, but
-	// the server will not negotiate it.
+	// When NoEncryptThenMACHash is specified, do not use Encrypt-then-MAC has
+	// algorithms.
+
+	equal := func(list1, list2 []string) bool {
+		if len(list1) != len(list2) {
+			return false
+		}
+		for i, entry := range list1 {
+			if list2[i] != entry {
+				return false
+			}
+		}
+		return true
+	}
+
+	// Psiphon transforms assume that default algorithms are configured.
+	if (t.config.NoEncryptThenMACHash || t.config.KEXPRNGSeed != nil) &&
+		(!equal(t.config.KeyExchanges, supportedKexAlgos) ||
+			!equal(t.config.Ciphers, preferredCiphers) ||
+			!equal(t.config.MACs, supportedMACs)) {
+
+		return errors.New("ssh: custom algorithm preferences not supported")
+	}
+
+	// This is the list of supported non-Encrypt-then-MAC algorithms from
+	// https://github.com/Psiphon-Labs/psiphon-tunnel-core/blob/3ef11effe6acd9
+	// 2c3aefd140ee09c42a1f15630b/psiphon/common/crypto/ssh/common.go#L60
 	//
-	// The "t.remoteAddr != nil" condition should be true only
-	// for clients.
+	// With Encrypt-then-MAC hash algorithms, packet length is transmitted in
+	// plaintext, which aids in traffic analysis.
 	//
-	if t.remoteAddr != nil && t.config.KEXPRNGSeed != nil {
+	// When using obfuscated SSH, where only the initial, unencrypted
+	// packets are obfuscated, NoEncryptThenMACHash should be set.
+	noEncryptThenMACs := []string{"hmac-sha2-256", "hmac-sha1", "hmac-sha1-96"}
+
+	if t.config.NoEncryptThenMACHash {
+		msg.MACsClientServer = noEncryptThenMACs
+		msg.MACsServerClient = noEncryptThenMACs
+	}
+
+	if t.config.KEXPRNGSeed != nil {
 
 		PRNG := prng.NewPRNGWithSeed(t.config.KEXPRNGSeed)
 
-		permute := func(list []string) []string {
+		permute := func(PRNG *prng.PRNG, list []string) []string {
 			newList := make([]string, len(list))
 			perm := PRNG.Perm(len(list))
 			for i, j := range perm {
@@ -495,7 +530,7 @@ func (t *handshakeTransport) sendKexInit() error {
 			return newList
 		}
 
-		truncate := func(list []string) []string {
+		truncate := func(PRNG *prng.PRNG, list []string) []string {
 			cut := len(list)
 			for ; cut > 1; cut-- {
 				if !PRNG.FlipCoin() {
@@ -505,39 +540,81 @@ func (t *handshakeTransport) sendKexInit() error {
 			return list[:cut]
 		}
 
-		msg.KexAlgos = truncate(permute(t.config.KeyExchanges))
-		ciphers := truncate(permute(t.config.Ciphers))
+		retain := func(PRNG *prng.PRNG, list []string, item string) []string {
+			for _, entry := range list {
+				if entry == item {
+					return list
+				}
+			}
+			replace := PRNG.Intn(len(list))
+			list[replace] = item
+			return list
+		}
+
+		msg.KexAlgos = truncate(PRNG, permute(PRNG, msg.KexAlgos))
+		ciphers := truncate(PRNG, permute(PRNG, msg.CiphersClientServer))
 		msg.CiphersClientServer = ciphers
 		msg.CiphersServerClient = ciphers
-		MACs := truncate(permute(t.config.MACs))
+		MACs := truncate(PRNG, permute(PRNG, msg.MACsClientServer))
 		msg.MACsClientServer = MACs
 		msg.MACsServerClient = MACs
 
 		if len(t.hostKeys) > 0 {
-			msg.ServerHostKeyAlgos = permute(msg.ServerHostKeyAlgos)
+			msg.ServerHostKeyAlgos = permute(PRNG, msg.ServerHostKeyAlgos)
 		} else {
-			serverHostKeyAlgos := truncate(permute(msg.ServerHostKeyAlgos))
-
 			// Must offer KeyAlgoRSA to Psiphon server.
-			hasKeyAlgoRSA := false
-			for _, algo := range serverHostKeyAlgos {
-				if algo == KeyAlgoRSA {
-					hasKeyAlgoRSA = true
-					break
-				}
+			msg.ServerHostKeyAlgos = retain(
+				PRNG,
+				truncate(PRNG, permute(PRNG, msg.ServerHostKeyAlgos)),
+				KeyAlgoRSA)
+		}
+
+		if t.config.PeerKEXPRNGSeed != nil {
+
+			// Generate the peer KEX and make adjustments if negotiation would
+			// fail. This assumes that PeerKEXPRNGSeed remains static (in
+			// Psiphon, the peer is the server and PeerKEXPRNGSeed is derived
+			// from the server entry); and that the PRNG is invoked in the
+			// exact same order on the peer (i.e., the code block immediately
+			// above is what the peer runs); and that the peer sets
+			// NoEncryptThenMACHash in the same cases.
+
+			PeerPRNG := prng.NewPRNGWithSeed(t.config.PeerKEXPRNGSeed)
+
+			peerKexAlgos := truncate(PeerPRNG, permute(PeerPRNG, supportedKexAlgos))
+			if _, err := findCommon("", msg.KexAlgos, peerKexAlgos); err != nil {
+				msg.KexAlgos = retain(PRNG, msg.KexAlgos, peerKexAlgos[0])
 			}
-			if !hasKeyAlgoRSA {
-				replace := PRNG.Intn(len(serverHostKeyAlgos))
-				serverHostKeyAlgos[replace] = KeyAlgoRSA
+
+			peerCiphers := truncate(PeerPRNG, permute(PeerPRNG, preferredCiphers))
+			if _, err := findCommon("", ciphers, peerCiphers); err != nil {
+				ciphers = retain(PRNG, ciphers, peerCiphers[0])
+				msg.CiphersClientServer = ciphers
+				msg.CiphersServerClient = ciphers
 			}
 
-			msg.ServerHostKeyAlgos = serverHostKeyAlgos
+			peerMACs := supportedMACs
+			if t.config.NoEncryptThenMACHash {
+				peerMACs = noEncryptThenMACs
+			}
+
+			peerMACs = truncate(PeerPRNG, permute(PeerPRNG, peerMACs))
+			if _, err := findCommon("", MACs, peerMACs); err != nil {
+				MACs = retain(PRNG, MACs, peerMACs[0])
+				msg.MACsClientServer = MACs
+				msg.MACsServerClient = MACs
+			}
 		}
 
-		// Offer "zlib@openssh.com", which is offered by OpenSSH.
-		// Since server only supports "none", must always offer "none"
+		// Offer "zlib@openssh.com", which is offered by OpenSSH. Compression
+		// is not actually implemented, but since "zlib@openssh.com"
+		// compression is delayed until after authentication
+		// (https://www.openssh.com/txt/draft-miller-secsh-compression-
+		// delayed-00.txt), an unauthenticated probe of the SSH server will
+		// not detect this. "none" is always included to ensure negotiation
+		// succeeds.
 		if PRNG.FlipCoin() {
-			compressions := []string{"none", "zlib@openssh.com"}
+			compressions := permute(PRNG, []string{"none", "zlib@openssh.com"})
 			msg.CompressionClientServer = compressions
 			msg.CompressionServerClient = compressions
 		}

+ 157 - 0
psiphon/common/crypto/ssh/randomized_kex_test.go

@@ -0,0 +1,157 @@
+/*
+ * Copyright (c) 2019, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package ssh
+
+import (
+	"bytes"
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"errors"
+	"net"
+	"testing"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"golang.org/x/sync/errgroup"
+)
+
+func TestRandomizedSSHKEXes(t *testing.T) {
+
+	rsaKey, err := rsa.GenerateKey(rand.Reader, 4096)
+	if err != nil {
+		t.Fatalf("rsa.GenerateKey failed: %s", err)
+	}
+
+	signer, err := NewSignerFromKey(rsaKey)
+	if err != nil {
+		t.Fatalf("NewSignerFromKey failed: %s", err)
+	}
+
+	publicKey := signer.PublicKey()
+
+	username := "username"
+	password := "password"
+
+	for _, doPeerKEXPRNGSeed := range []bool{true, false} {
+
+		failed := false
+
+		for i := 0; i < 1000; i++ {
+
+			clientSeed, err := prng.NewSeed()
+			if err != nil {
+				t.Fatalf("prng.NewSeed failed: %s", err)
+			}
+
+			serverSeed, err := prng.NewSeed()
+			if err != nil {
+				t.Fatalf("prng.NewSeed failed: %s", err)
+			}
+
+			clientConn, serverConn, err := netPipe()
+			if err != nil {
+				t.Fatalf("netPipe failed: %s", err)
+			}
+
+			testGroup, _ := errgroup.WithContext(context.Background())
+
+			// Client
+
+			testGroup.Go(func() error {
+
+				certChecker := &CertChecker{
+					HostKeyFallback: func(addr string, remote net.Addr, key PublicKey) error {
+						if !bytes.Equal(publicKey.Marshal(), key.Marshal()) {
+							return errors.New("unexpected host public key")
+						}
+						return nil
+					},
+				}
+
+				clientConfig := &ClientConfig{
+					User:            username,
+					Auth:            []AuthMethod{Password(password)},
+					HostKeyCallback: certChecker.CheckHostKey,
+				}
+
+				clientConfig.KEXPRNGSeed = clientSeed
+
+				if doPeerKEXPRNGSeed {
+					clientConfig.PeerKEXPRNGSeed = serverSeed
+				}
+
+				clientSSHConn, _, _, err := NewClientConn(clientConn, "", clientConfig)
+				if err != nil {
+					return err
+				}
+
+				clientSSHConn.Close()
+				clientConn.Close()
+				return nil
+			})
+
+			// Server
+
+			testGroup.Go(func() error {
+
+				insecurePasswordCallback := func(c ConnMetadata, pass []byte) (*Permissions, error) {
+					if c.User() == username && string(pass) == password {
+						return nil, nil
+					}
+					return nil, errors.New("authentication failed")
+				}
+
+				serverConfig := &ServerConfig{
+					PasswordCallback: insecurePasswordCallback,
+				}
+				serverConfig.AddHostKey(signer)
+
+				serverConfig.KEXPRNGSeed = serverSeed
+
+				serverSSHConn, _, _, err := NewServerConn(serverConn, serverConfig)
+				if err != nil {
+					return err
+				}
+
+				serverSSHConn.Close()
+				serverConn.Close()
+				return nil
+			})
+
+			err = testGroup.Wait()
+			if err != nil {
+
+				// Expect no failure to negotiates when setting PeerKEXPRNGSeed.
+				if doPeerKEXPRNGSeed {
+					t.Fatalf("goroutine failed: %s", err)
+
+				} else {
+					failed = true
+					break
+				}
+			}
+		}
+
+		// Expect at least one failure to negotiate when not setting PeerKEXPRNGSeed.
+		if !doPeerKEXPRNGSeed && !failed {
+			t.Fatalf("unexpected success")
+		}
+	}
+}

+ 17 - 7
psiphon/common/prng/prng.go

@@ -80,6 +80,21 @@ func NewSeed() (*Seed, error) {
 	return seed, nil
 }
 
+// NewSaltedSeed creates a new seed derived from an existing seed and a salt.
+// A HKDF is applied to the seed and salt.
+//
+// NewSaltedSeed is intended for use cases where a single seed needs to be
+// used in distinct contexts to produce independent random streams.
+func NewSaltedSeed(seed *Seed, salt string) (*Seed, error) {
+	saltedSeed := new(Seed)
+	_, err := io.ReadFull(
+		hkdf.New(sha256.New, seed[:], []byte(salt), nil), saltedSeed[:])
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+	return saltedSeed, nil
+}
+
 // PRNG is a seeded, unbiased PRNG based on chacha20.
 type PRNG struct {
 	rand                   *rand.Rand
@@ -110,14 +125,9 @@ func NewPRNGWithSeed(seed *Seed) *PRNG {
 }
 
 // NewPRNGWithSaltedSeed initializes a new PRNG using a seed derived from an
-// existing seed and a salt. A HKDF is applied to the seed and salt.
-//
-// NewPRNGWithSaltedSeed is intended for use cases where a single seed needs
-// to be used in distinct contexts to produce independent random streams.
+// existing seed and a salt with NewSaltedSeed.
 func NewPRNGWithSaltedSeed(seed *Seed, salt string) (*PRNG, error) {
-	saltedSeed := new(Seed)
-	_, err := io.ReadFull(
-		hkdf.New(sha256.New, seed[:], []byte(salt), nil), saltedSeed[:])
+	saltedSeed, err := NewSaltedSeed(seed, salt)
 	if err != nil {
 		return nil, common.ContextError(err)
 	}

+ 10 - 0
psiphon/common/protocol/protocol.go

@@ -20,11 +20,13 @@
 package protocol
 
 import (
+	"crypto/sha256"
 	"encoding/json"
 	"fmt"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/osl"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 )
 
 const (
@@ -319,3 +321,11 @@ type RandomStreamRequest struct {
 	UpstreamBytes   int `json:"u"`
 	DownstreamBytes int `json:"d"`
 }
+
+func DeriveServerKEXPRNGSeed(obfuscatedKey string) (*prng.Seed, error) {
+	// By convention, the obfuscatedKey will ofetn be a hex-encoded 32 byte value,
+	// but this isn't strictly required or validated, so we use SHA256 to map the
+	// obfuscatedKey to tne necessary 32-byte seed value.
+	seed := prng.Seed(sha256.Sum256([]byte(obfuscatedKey)))
+	return prng.NewSaltedSeed(&seed, "ssh-server-kex-randomization")
+}

+ 18 - 11
psiphon/server/tunnelServer.go

@@ -1121,27 +1121,34 @@ func (sshClient *sshClient) run(
 		}
 		sshServerConfig.AddHostKey(sshClient.sshServer.sshHostKey)
 
+		var err error
+
 		if protocol.TunnelProtocolUsesObfuscatedSSH(sshClient.tunnelProtocol) {
-			// This is the list of supported non-Encrypt-then-MAC algorithms from
-			// https://github.com/Psiphon-Labs/psiphon-tunnel-core/blob/3ef11effe6acd92c3aefd140ee09c42a1f15630b/psiphon/common/crypto/ssh/common.go#L60
-			//
-			// With Encrypt-then-MAC algorithms, packet length is transmitted in
-			// plaintext, which aids in traffic analysis; clients may still send
-			// Encrypt-then-MAC algorithms in their KEX_INIT message, but do not
-			// select these algorithms.
+			// With Encrypt-then-MAC hash algorithms, packet length is
+			// transmitted in plaintext, which aids in traffic analysis;
+			// clients may still send Encrypt-then-MAC algorithms in their
+			// KEX_INIT message, but do not select these algorithms.
 			//
 			// The exception is TUNNEL_PROTOCOL_SSH, which is intended to appear
 			// like SSH on the wire.
-			sshServerConfig.MACs = []string{"hmac-sha2-256", "hmac-sha1", "hmac-sha1-96"}
+			sshServerConfig.NoEncryptThenMACHash = true
+
+		} else {
+			// For TUNNEL_PROTOCOL_SSH only, randomize KEX.
+			if sshClient.sshServer.support.Config.ObfuscatedSSHKey != "" {
+				sshServerConfig.KEXPRNGSeed, err = protocol.DeriveServerKEXPRNGSeed(
+					sshClient.sshServer.support.Config.ObfuscatedSSHKey)
+				if err != nil {
+					err = common.ContextError(err)
+				}
+			}
 		}
 
 		result := &sshNewServerConnResult{}
 
 		// Wrap the connection in an SSH deobfuscator when required.
 
-		var err error
-
-		if protocol.TunnelProtocolUsesObfuscatedSSH(sshClient.tunnelProtocol) {
+		if err == nil && protocol.TunnelProtocolUsesObfuscatedSSH(sshClient.tunnelProtocol) {
 			// Note: NewObfuscatedSSHConn blocks on network I/O
 			// TODO: ensure this won't block shutdown
 			result.obfuscatedSSHConn, err = obfuscator.NewObfuscatedSSHConn(

+ 15 - 6
psiphon/tunnel.go

@@ -741,16 +741,25 @@ func dialTunnel(
 			sshClientConfig.Ciphers = []string{config.ObfuscatedSSHAlgorithms[1]}
 			sshClientConfig.MACs = []string{config.ObfuscatedSSHAlgorithms[2]}
 			sshClientConfig.HostKeyAlgorithms = []string{config.ObfuscatedSSHAlgorithms[3]}
+
 		} else {
-			// This is the list of supported non-Encrypt-then-MAC algorithms from
-			// https://github.com/Psiphon-Labs/psiphon-tunnel-core/blob/3ef11effe6acd92c3aefd140ee09c42a1f15630b/psiphon/common/crypto/ssh/common.go#L60
-			//
-			// With Encrypt-then-MAC algorithms, packet length is transmitted in
-			// plaintext, which aids in traffic analysis.
+			// With Encrypt-then-MAC hash algorithms, packet length is
+			// transmitted in plaintext, which aids in traffic analysis.
 			//
 			// TUNNEL_PROTOCOL_SSH is excepted since its KEX appears in plaintext,
 			// and the protocol is intended to look like SSH on the wire.
-			sshClientConfig.MACs = []string{"hmac-sha2-256", "hmac-sha1", "hmac-sha1-96"}
+			sshClientConfig.NoEncryptThenMACHash = true
+		}
+	} else {
+		// For TUNNEL_PROTOCOL_SSH only, the server is expected to randomize
+		// its KEX; setting PeerKEXPRNGSeed will ensure successful negotiation
+		// betweem two randomized KEXes.
+		if dialParams.ServerEntry.SshObfuscatedKey != "" {
+			sshClientConfig.PeerKEXPRNGSeed, err = protocol.DeriveServerKEXPRNGSeed(
+				dialParams.ServerEntry.SshObfuscatedKey)
+			if err != nil {
+				return nil, common.ContextError(err)
+			}
 		}
 	}