Skip to content

Commit c96bba2

Browse files
drakkandjmdjm
authored andcommitted
ssh: add mlkem768x25519-sha256 Key Exchange algorithm
mlkem768x25519-sha256 requires the crypto/mlkem package introduced in Go 1.24. Thanks to Damien Miller for posting an early version to the OpenSSH mailing list. Co-authored-by: Damien Miller <djm@mindrot.org> Change-Id: I4235cf906903524a9a97283834cc8f43b5f76f91 Reviewed-on: https://go-review.googlesource.com/c/crypto/+/646075 Reviewed-by: Filippo Valsorda <filippo@golang.org> Reviewed-by: Dmitri Shuralyov <dmitshur@google.com> Reviewed-by: Carlos Amedee <carlos@golang.org> Auto-Submit: Nicola Murino <nicola.murino@gmail.com> LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
1 parent 6b13eef commit c96bba2

File tree

4 files changed

+631
-1
lines changed

4 files changed

+631
-1
lines changed

ssh/mlkem.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright 2024 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build go1.24
6+
7+
package ssh
8+
9+
import (
10+
"crypto"
11+
"crypto/mlkem"
12+
"crypto/sha256"
13+
"errors"
14+
"fmt"
15+
"io"
16+
"runtime"
17+
"slices"
18+
19+
"golang.org/x/crypto/curve25519"
20+
)
21+
22+
const (
23+
kexAlgoMLKEM768xCurve25519SHA256 = "mlkem768x25519-sha256"
24+
)
25+
26+
func init() {
27+
// After Go 1.24rc1 mlkem swapped the order of return values of Encapsulate.
28+
// See #70950.
29+
if runtime.Version() == "go1.24rc1" {
30+
return
31+
}
32+
supportedKexAlgos = slices.Insert(supportedKexAlgos, 0, kexAlgoMLKEM768xCurve25519SHA256)
33+
preferredKexAlgos = slices.Insert(preferredKexAlgos, 0, kexAlgoMLKEM768xCurve25519SHA256)
34+
kexAlgoMap[kexAlgoMLKEM768xCurve25519SHA256] = &mlkem768WithCurve25519sha256{}
35+
}
36+
37+
// mlkem768WithCurve25519sha256 implements the hybrid ML-KEM768 with
38+
// curve25519-sha256 key exchange method, as described by
39+
// draft-kampanakis-curdle-ssh-pq-ke-05 section 2.3.3.
40+
type mlkem768WithCurve25519sha256 struct{}
41+
42+
func (kex *mlkem768WithCurve25519sha256) Client(c packetConn, rand io.Reader, magics *handshakeMagics) (*kexResult, error) {
43+
var c25519kp curve25519KeyPair
44+
if err := c25519kp.generate(rand); err != nil {
45+
return nil, err
46+
}
47+
48+
seed := make([]byte, mlkem.SeedSize)
49+
if _, err := io.ReadFull(rand, seed); err != nil {
50+
return nil, err
51+
}
52+
53+
mlkemDk, err := mlkem.NewDecapsulationKey768(seed)
54+
if err != nil {
55+
return nil, err
56+
}
57+
58+
hybridKey := append(mlkemDk.EncapsulationKey().Bytes(), c25519kp.pub[:]...)
59+
if err := c.writePacket(Marshal(&kexECDHInitMsg{hybridKey})); err != nil {
60+
return nil, err
61+
}
62+
63+
packet, err := c.readPacket()
64+
if err != nil {
65+
return nil, err
66+
}
67+
68+
var reply kexECDHReplyMsg
69+
if err = Unmarshal(packet, &reply); err != nil {
70+
return nil, err
71+
}
72+
73+
if len(reply.EphemeralPubKey) != mlkem.CiphertextSize768+32 {
74+
return nil, errors.New("ssh: peer's mlkem768x25519 public value has wrong length")
75+
}
76+
77+
// Perform KEM decapsulate operation to obtain shared key from ML-KEM.
78+
mlkem768Secret, err := mlkemDk.Decapsulate(reply.EphemeralPubKey[:mlkem.CiphertextSize768])
79+
if err != nil {
80+
return nil, err
81+
}
82+
83+
// Complete Curve25519 ECDH to obtain its shared key.
84+
c25519Secret, err := curve25519.X25519(c25519kp.priv[:], reply.EphemeralPubKey[mlkem.CiphertextSize768:])
85+
if err != nil {
86+
return nil, fmt.Errorf("ssh: peer's mlkem768x25519 public value is not valid: %w", err)
87+
}
88+
// Compute actual shared key.
89+
h := sha256.New()
90+
h.Write(mlkem768Secret)
91+
h.Write(c25519Secret)
92+
secret := h.Sum(nil)
93+
94+
h.Reset()
95+
magics.write(h)
96+
writeString(h, reply.HostKey)
97+
writeString(h, hybridKey)
98+
writeString(h, reply.EphemeralPubKey)
99+
100+
K := make([]byte, stringLength(len(secret)))
101+
marshalString(K, secret)
102+
h.Write(K)
103+
104+
return &kexResult{
105+
H: h.Sum(nil),
106+
K: K,
107+
HostKey: reply.HostKey,
108+
Signature: reply.Signature,
109+
Hash: crypto.SHA256,
110+
}, nil
111+
}
112+
113+
func (kex *mlkem768WithCurve25519sha256) Server(c packetConn, rand io.Reader, magics *handshakeMagics, priv AlgorithmSigner, algo string) (*kexResult, error) {
114+
packet, err := c.readPacket()
115+
if err != nil {
116+
return nil, err
117+
}
118+
119+
var kexInit kexECDHInitMsg
120+
if err = Unmarshal(packet, &kexInit); err != nil {
121+
return nil, err
122+
}
123+
124+
if len(kexInit.ClientPubKey) != mlkem.EncapsulationKeySize768+32 {
125+
return nil, errors.New("ssh: peer's ML-KEM768/curve25519 public value has wrong length")
126+
}
127+
128+
encapsulationKey, err := mlkem.NewEncapsulationKey768(kexInit.ClientPubKey[:mlkem.EncapsulationKeySize768])
129+
if err != nil {
130+
return nil, fmt.Errorf("ssh: peer's ML-KEM768 encapsulation key is not valid: %w", err)
131+
}
132+
// Perform KEM encapsulate operation to obtain ciphertext and shared key.
133+
mlkem768Secret, mlkem768Ciphertext := encapsulationKey.Encapsulate()
134+
135+
// Perform server side of Curve25519 ECDH to obtain server public value and
136+
// shared key.
137+
var c25519kp curve25519KeyPair
138+
if err := c25519kp.generate(rand); err != nil {
139+
return nil, err
140+
}
141+
c25519Secret, err := curve25519.X25519(c25519kp.priv[:], kexInit.ClientPubKey[mlkem.EncapsulationKeySize768:])
142+
if err != nil {
143+
return nil, fmt.Errorf("ssh: peer's ML-KEM768/curve25519 public value is not valid: %w", err)
144+
}
145+
hybridKey := append(mlkem768Ciphertext, c25519kp.pub[:]...)
146+
147+
// Compute actual shared key.
148+
h := sha256.New()
149+
h.Write(mlkem768Secret)
150+
h.Write(c25519Secret)
151+
secret := h.Sum(nil)
152+
153+
hostKeyBytes := priv.PublicKey().Marshal()
154+
155+
h.Reset()
156+
magics.write(h)
157+
writeString(h, hostKeyBytes)
158+
writeString(h, kexInit.ClientPubKey)
159+
writeString(h, hybridKey)
160+
161+
K := make([]byte, stringLength(len(secret)))
162+
marshalString(K, secret)
163+
h.Write(K)
164+
165+
H := h.Sum(nil)
166+
167+
sig, err := signAndMarshal(priv, rand, H, algo)
168+
if err != nil {
169+
return nil, err
170+
}
171+
172+
reply := kexECDHReplyMsg{
173+
EphemeralPubKey: hybridKey,
174+
HostKey: hostKeyBytes,
175+
Signature: sig,
176+
}
177+
if err := c.writePacket(Marshal(&reply)); err != nil {
178+
return nil, err
179+
}
180+
return &kexResult{
181+
H: H,
182+
K: K,
183+
HostKey: hostKeyBytes,
184+
Signature: sig,
185+
Hash: crypto.SHA256,
186+
}, nil
187+
}

ssh/test/recording_client_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,11 @@ func (test *clientTest) run(t *testing.T, write bool) {
232232

233233
func recordingsClientConfig() *ssh.ClientConfig {
234234
config := clientConfig()
235+
// Remove ML-KEM since it only works with Go 1.24.
236+
config.SetDefaults()
237+
if config.KeyExchanges[0] == "mlkem768x25519-sha256" {
238+
config.KeyExchanges = config.KeyExchanges[1:]
239+
}
235240
config.Rand = sha3.NewShake128()
236241
config.Auth = []ssh.AuthMethod{
237242
ssh.PublicKeys(testSigners["rsa"]),

ssh/test/recording_server_test.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,12 @@ func recordingsServerConfig() *ssh.ServerConfig {
218218
return nil, nil
219219
},
220220
}
221+
config.SetDefaults()
222+
// Remove ML-KEM since it only works with Go 1.24.
223+
config.SetDefaults()
224+
if config.KeyExchanges[0] == "mlkem768x25519-sha256" {
225+
config.KeyExchanges = config.KeyExchanges[1:]
226+
}
221227
config.AddHostKey(testSigners["rsa"])
222228
return config
223229
}
@@ -230,7 +236,8 @@ func TestServerKeyExchanges(t *testing.T) {
230236
for _, kex := range config.KeyExchanges {
231237
// Exclude ecdh for now, to make them determistic we should use see a
232238
// stream of fixed bytes as the random source.
233-
if !strings.HasPrefix(kex, "ecdh-") {
239+
// Exclude ML-KEM because server side is not deterministic.
240+
if !strings.HasPrefix(kex, "ecdh-") && !strings.HasPrefix(kex, "mlkem") {
234241
keyExchanges = append(keyExchanges, kex)
235242
}
236243
}

0 commit comments

Comments
 (0)