Skip to content

Commit bb41b3d

Browse files
drakkangopherbot
authored andcommitted
ssh: improve DH GEX group selection using PreferredBits
Previously, the server selected the Diffie-Hellman group based solely on the MaxBits value provided by the client. This resulted in suboptimal modulus selection, often ignoring the client's PreferredBits or selecting a larger-than-necessary group. This change implements a "best fit" selection algorithm similar to OpenSSH's choose_dh logic. It attempts to find the smallest available group larger than or equal to the client's PreferredBits, falling back to the largest available group within the accepted range if no group above the preference is available. Additionally, this commit caches the parsed Oakley groups using sync.OnceValue, avoiding repeated big.Int parsing on every handshake while keeping the cost out of package initialization. This issue was found during a security audit by NCC Group Cryptography Services, sponsored by Teleport, and was assessed and is being fixed as a non-security bug. Change-Id: Idfa81bbcf354a7fb7b541cb4bbeb6e4a0181398a Reviewed-on: https://go-review.googlesource.com/c/crypto/+/782424 Auto-Submit: Nicola Murino <nicola.murino@gmail.com> Reviewed-by: Filippo Valsorda <filippo@golang.org> Reviewed-by: Mark Freeman <markfreeman@google.com> LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: David Chase <drchase@google.com>
1 parent e04e721 commit bb41b3d

2 files changed

Lines changed: 149 additions & 9 deletions

File tree

ssh/kex.go

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"io"
1717
"math/big"
1818
"slices"
19+
"sync"
1920

2021
"golang.org/x/crypto/curve25519"
2122
)
@@ -718,15 +719,9 @@ func (gex *dhGEXSHA) Server(c packetConn, randSource io.Reader, magics *handshak
718719
kexDHGexRequest.MaxBits, kexDHGexRequest.PreferredBits)
719720
}
720721

721-
var p *big.Int
722-
// We hardcode sending Oakley Group 14 (2048 bits), Oakley Group 15 (3072
723-
// bits) or Oakley Group 16 (4096 bits), based on the requested max size.
724-
if kexDHGexRequest.MaxBits < 3072 {
725-
p, _ = new(big.Int).SetString(oakleyGroup14, 16)
726-
} else if kexDHGexRequest.MaxBits < 4096 {
727-
p, _ = new(big.Int).SetString(oakleyGroup15, 16)
728-
} else {
729-
p, _ = new(big.Int).SetString(oakleyGroup16, 16)
722+
p, err := chooseDH(kexDHGexRequest)
723+
if err != nil {
724+
return nil, err
730725
}
731726

732727
g := big.NewInt(2)
@@ -805,3 +800,65 @@ func (gex *dhGEXSHA) Server(c packetConn, randSource io.Reader, magics *handshak
805800
Hash: gex.hashFunc,
806801
}, err
807802
}
803+
804+
type dhKEXGroup struct {
805+
size int
806+
p *big.Int
807+
}
808+
809+
// supportedDHKEXGroups returns the DH groups the server is willing to offer
810+
// for diffie-hellman-group-exchange-* key exchanges. The list is built lazily
811+
// on first use to keep the hex-to-big.Int parse out of package initialization.
812+
var supportedDHKEXGroups = sync.OnceValue(func() []dhKEXGroup {
813+
specs := []struct {
814+
size int
815+
hex string
816+
}{
817+
{2048, oakleyGroup14},
818+
{3072, oakleyGroup15},
819+
{4096, oakleyGroup16},
820+
}
821+
out := make([]dhKEXGroup, 0, len(specs))
822+
for _, s := range specs {
823+
p, _ := new(big.Int).SetString(s.hex, 16)
824+
out = append(out, dhKEXGroup{size: s.size, p: p})
825+
}
826+
return out
827+
})
828+
829+
// chooseDH picks a DH group for the given client request, mirroring the
830+
// algorithm used by OpenSSH's choose_dh in dh.c: prefer the smallest known
831+
// group larger than or equal to the client's PreferredBits, and otherwise pick
832+
// the largest group within the accepted [MinBits, MaxBits] range.
833+
func chooseDH(req kexDHGexRequestMsg) (*big.Int, error) {
834+
var best *big.Int
835+
bestSize := 0
836+
wantBits := int(req.PreferredBits)
837+
838+
for _, group := range supportedDHKEXGroups() {
839+
if uint32(group.size) < req.MinBits || uint32(group.size) > req.MaxBits {
840+
continue
841+
}
842+
843+
if bestSize == 0 {
844+
best = group.p
845+
bestSize = group.size
846+
continue
847+
}
848+
849+
closerFromAbove := group.size >= wantBits && group.size < bestSize
850+
closerFromBelow := group.size > bestSize && bestSize < wantBits
851+
852+
if closerFromAbove || closerFromBelow {
853+
best = group.p
854+
bestSize = group.size
855+
}
856+
}
857+
858+
if bestSize == 0 {
859+
return nil, fmt.Errorf("ssh: no suitable DH group found for request min: %d, preferred: %d, max: %d",
860+
req.MinBits, req.PreferredBits, req.MaxBits)
861+
}
862+
863+
return best, nil
864+
}

ssh/kex_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package ssh
99
import (
1010
"crypto/rand"
1111
"fmt"
12+
"math/big"
1213
"reflect"
1314
"sync"
1415
"testing"
@@ -65,6 +66,88 @@ func TestKexes(t *testing.T) {
6566
}
6667
}
6768

69+
func TestChooseDH(t *testing.T) {
70+
oakley := map[int]string{
71+
2048: oakleyGroup14,
72+
3072: oakleyGroup15,
73+
4096: oakleyGroup16,
74+
}
75+
expected := func(size int) *big.Int {
76+
hex, ok := oakley[size]
77+
if !ok {
78+
t.Fatalf("test setup: no Oakley group for size %d", size)
79+
}
80+
p, _ := new(big.Int).SetString(hex, 16)
81+
return p
82+
}
83+
84+
tests := []struct {
85+
name string
86+
request kexDHGexRequestMsg
87+
want int // expected bit size; 0 means error expected
88+
wantErr bool
89+
}{
90+
{
91+
name: "Standard 2048 request",
92+
request: kexDHGexRequestMsg{MinBits: 1024, PreferredBits: 2048, MaxBits: 8192},
93+
want: 2048,
94+
},
95+
{
96+
name: "Standard 3072 request",
97+
request: kexDHGexRequestMsg{MinBits: 1024, PreferredBits: 3072, MaxBits: 8192},
98+
want: 3072,
99+
},
100+
{
101+
name: "Standard 4096 request",
102+
request: kexDHGexRequestMsg{MinBits: 1024, PreferredBits: 4096, MaxBits: 8192},
103+
want: 4096,
104+
},
105+
{
106+
name: "Preferred 2500 -> Expect 3072 (round up)",
107+
request: kexDHGexRequestMsg{MinBits: 1024, PreferredBits: 2500, MaxBits: 8192},
108+
want: 3072,
109+
},
110+
{
111+
name: "Preferred 3500 -> Expect 4096 (round up)",
112+
request: kexDHGexRequestMsg{MinBits: 1024, PreferredBits: 3500, MaxBits: 8192},
113+
want: 4096,
114+
},
115+
{
116+
name: "Preferred too high (8000) -> Expect 4096 (cap at max available)",
117+
request: kexDHGexRequestMsg{MinBits: 1024, PreferredBits: 8000, MaxBits: 8192},
118+
want: 4096,
119+
},
120+
{
121+
name: "No group in range",
122+
request: kexDHGexRequestMsg{MinBits: 2500, PreferredBits: 2500, MaxBits: 2900},
123+
wantErr: true,
124+
},
125+
}
126+
127+
for _, tt := range tests {
128+
t.Run(tt.name, func(t *testing.T) {
129+
got, err := chooseDH(tt.request)
130+
131+
if (err != nil) != tt.wantErr {
132+
t.Errorf("chooseDH() error = %v, wantErr %t", err, tt.wantErr)
133+
return
134+
}
135+
if tt.wantErr {
136+
return
137+
}
138+
if got == nil {
139+
t.Fatalf("chooseDH() returned nil big.Int but expected success")
140+
}
141+
if got.BitLen() != tt.want {
142+
t.Errorf("chooseDH() got size = %d, want %d", got.BitLen(), tt.want)
143+
}
144+
if want := expected(tt.want); got.Cmp(want) != 0 {
145+
t.Errorf("chooseDH() returned the wrong group for size %d", tt.want)
146+
}
147+
})
148+
}
149+
}
150+
68151
func BenchmarkKexes(b *testing.B) {
69152
type kexResultErr struct {
70153
result *kexResult

0 commit comments

Comments
 (0)