Skip to content

Commit c5e1d9d

Browse files
committed
mlkem768x25519-sha256 support
Add support for hybrid key exchange method combining ML-KEM-768, a post-quantum crypto module-lattice-based key encapsulation method based on the Module Learning with Errors (M-LWE) problem, combined with X25519, a classical Curve25519 elliptic curve Diffie-Hellman exchange algorithm based on the Discrete Log Problem. This is based on draft-ietf-sshm-mlkem-hybrid-kex-03.
1 parent c6f5504 commit c5e1d9d

File tree

13 files changed

+683
-24
lines changed

13 files changed

+683
-24
lines changed

.github/workflows/ci.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ jobs:
1212
runs-on: ubuntu-latest
1313
strategy:
1414
matrix:
15-
java: [17, 21]
15+
java: [17, 21, 25]
1616
steps:
1717
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
1818
- name: Set up JDK ${{ matrix.java }}
@@ -29,13 +29,15 @@ jobs:
2929
- name: Grant execute permission for gradlew
3030
run: chmod +x gradlew
3131
- name: Build with Gradle
32-
run: ./gradlew build jacocoTestReport --info
32+
run: ./gradlew build jacocoTestReport --info -PjdkVersion=${{ matrix.java }}
33+
env:
34+
SSH_TEST_REQUIRE_MLKEM: ${{ matrix.java >= 25 }}
3335
- name: Upload to Sonatype
3436
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && matrix.java == '17'
3537
run: |
3638
echo "${{ secrets.MAVEN_GPG_PRIVATE_KEY }}" > ~/.gradle/secring.gpg.b64
3739
base64 -d ~/.gradle/secring.gpg.b64 > ~/.gradle/secring.gpg
38-
./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository -PsonatypeUsername=${SONATYPE_USERNAME} -PsonatypePassword=${SONATYPE_PASSWORD} -Psigning.keyId=${GPG_KEYID} -Psigning.secretKeyRingFile=$(echo ~/.gradle/secring.gpg) -Psigning.password=${GPG_PASSWORD}
40+
./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository -PjdkVersion=${{ matrix.java }} -PsonatypeUsername=${SONATYPE_USERNAME} -PsonatypePassword=${SONATYPE_PASSWORD} -Psigning.keyId=${GPG_KEYID} -Psigning.secretKeyRingFile=$(echo ~/.gradle/secring.gpg) -Psigning.password=${GPG_PASSWORD}
3941
env:
4042
GPG_KEYID: ${{ secrets.MAVEN_GPG_KEYID }}
4143
GPG_PASSWORD: ${{ secrets.MAVEN_GPG_PASSPHRASE }}

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ https://opensource.org/licenses/BSD-3-Clause).
3838
* RSA ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-6.6))
3939

4040
##### Key exchange:
41+
* mlkem768x25519-sha256 ([draft-ietf-sshm-mlkem-hybrid-kex](https://datatracker.ietf.org/doc/draft-ietf-sshm-mlkem-hybrid-kex/) (depends on JEP-496 support)
4142
* ecdh-sha2-nistp521 ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-4))
4243
* ecdh-sha2-nistp384 ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-4))
4344
* ecdh-sha2-nistp256 ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-4))

build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ java {
6363
withJavadocJar()
6464
withSourcesJar()
6565
toolchain {
66-
languageVersion.set(JavaLanguageVersion.of(11))
66+
val jdkVersion = (project.findProperty("jdkVersion") as String?)?.toIntOrNull() ?: 11
67+
languageVersion.set(JavaLanguageVersion.of(jdkVersion))
6768
}
6869
}
6970

src/main/java/com/trilead/ssh2/crypto/KeyMaterial.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
package com.trilead.ssh2.crypto;
33

44

5-
import java.math.BigInteger;
6-
75
import com.trilead.ssh2.crypto.digest.HashForSSH2Types;
86

97
/**
@@ -21,7 +19,7 @@ public class KeyMaterial
2119
public byte[] integrity_key_client_to_server;
2220
public byte[] integrity_key_server_to_client;
2321

24-
private static byte[] calculateKey(HashForSSH2Types sh, BigInteger K, byte[] H, byte type, byte[] SessionID,
22+
private static byte[] calculateKey(HashForSSH2Types sh, byte[] K, byte[] H, byte type, byte[] SessionID,
2523
int keyLength)
2624
{
2725
byte[] res = new byte[keyLength];
@@ -36,7 +34,7 @@ private static byte[] calculateKey(HashForSSH2Types sh, BigInteger K, byte[] H,
3634
byte[][] tmp = new byte[numRounds][];
3735

3836
sh.reset();
39-
sh.updateBigInt(K);
37+
sh.updateByteString(K);
4038
sh.updateBytes(H);
4139
sh.updateByte(type);
4240
sh.updateBytes(SessionID);
@@ -53,7 +51,7 @@ private static byte[] calculateKey(HashForSSH2Types sh, BigInteger K, byte[] H,
5351

5452
for (int i = 1; i < numRounds; i++)
5553
{
56-
sh.updateBigInt(K);
54+
sh.updateByteString(K);
5755
sh.updateBytes(H);
5856

5957
for (int j = 0; j < i; j++)
@@ -70,7 +68,7 @@ private static byte[] calculateKey(HashForSSH2Types sh, BigInteger K, byte[] H,
7068
return res;
7169
}
7270

73-
public static KeyMaterial create(String hashAlgo, byte[] H, BigInteger K, byte[] SessionID, int keyLengthCS,
71+
public static KeyMaterial create(String hashAlgo, byte[] H, byte[] K, byte[] SessionID, int keyLengthCS,
7472
int blockSizeCS, int macLengthCS, int keyLengthSC, int blockSizeSC, int macLengthSC)
7573
throws IllegalArgumentException
7674
{

src/main/java/com/trilead/ssh2/crypto/dh/DhGroupExchange.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,14 @@ public BigInteger getE()
6060
}
6161

6262
/**
63-
* @return Returns the shared secret k.
63+
* @return Returns the shared secret k as a byte array for key derivation.
6464
*/
65-
public BigInteger getK()
65+
public byte[] getK()
6666
{
6767
if (k == null)
6868
throw new IllegalStateException("Shared secret not yet known, need f first!");
6969

70-
return k;
70+
return k.toByteArray();
7171
}
7272

7373
/**
@@ -107,7 +107,7 @@ public byte[] calculateH(String hashAlgo, byte[] clientversion, byte[] serverver
107107
hash.updateBigInt(g);
108108
hash.updateBigInt(e);
109109
hash.updateBigInt(f);
110-
hash.updateBigInt(k);
110+
hash.updateByteString(getK());
111111

112112
return hash.getDigest();
113113
}

src/main/java/com/trilead/ssh2/crypto/dh/GenericDhExchange.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,16 @@ public abstract class GenericDhExchange
2121

2222
/* Shared secret */
2323

24-
BigInteger sharedSecret;
24+
protected BigInteger sharedSecret;
2525

2626
protected GenericDhExchange()
2727
{
2828
}
2929

3030
public static GenericDhExchange getInstance(String algo) {
31+
if (MlKemHybridExchange.NAME.equals(algo)) {
32+
return new MlKemHybridExchange();
33+
}
3134
if (Curve25519Exchange.NAME.equals(algo) || Curve25519Exchange.ALT_NAME.equals(algo)) {
3235
return new Curve25519Exchange();
3336
}
@@ -53,15 +56,15 @@ public static GenericDhExchange getInstance(String algo) {
5356
protected abstract byte[] getServerE();
5457

5558
/**
56-
* @return Returns the shared secret k.
59+
* @return Returns the shared secret k as a byte array for key derivation.
5760
* @throws IllegalStateException if the shared secret is not available.
5861
*/
59-
public BigInteger getK()
62+
public byte[] getK()
6063
{
6164
if (sharedSecret == null)
6265
throw new IllegalStateException("Shared secret not yet known, need f first!");
6366

64-
return sharedSecret;
67+
return sharedSecret.toByteArray();
6568
}
6669

6770
/**
@@ -88,7 +91,7 @@ public byte[] calculateH(byte[] clientversion, byte[] serverversion, byte[] clie
8891
hash.updateByteString(hostKey);
8992
hash.updateByteString(getE());
9093
hash.updateByteString(getServerE());
91-
hash.updateBigInt(sharedSecret);
94+
hash.updateByteString(getK());
9295

9396
return hash.getDigest();
9497
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
package com.trilead.ssh2.crypto.dh;
2+
3+
import com.google.crypto.tink.subtle.X25519;
4+
import java.io.IOException;
5+
import java.lang.reflect.Method;
6+
import java.math.BigInteger;
7+
import java.security.InvalidKeyException;
8+
import java.security.KeyFactory;
9+
import java.security.KeyPair;
10+
import java.security.KeyPairGenerator;
11+
import java.security.MessageDigest;
12+
import java.security.NoSuchAlgorithmException;
13+
import java.security.PrivateKey;
14+
import java.security.spec.PKCS8EncodedKeySpec;
15+
16+
/**
17+
* ML-KEM-768 hybrid key exchange implementation (mlkem768x25519-sha256).
18+
* Combines post-quantum ML-KEM-768 with classical X25519 key exchange.
19+
* Uses reflection to access Java 23+ KEM APIs while maintaining compatibility with Java 11+.
20+
* Implements draft-ietf-sshm-mlkem-hybrid-kex-03 specification.
21+
*/
22+
public class MlKemHybridExchange extends GenericDhExchange {
23+
24+
public static final String NAME = "mlkem768x25519-sha256";
25+
private static final int MLKEM768_PUBLIC_KEY_SIZE = 1184;
26+
private static final int MLKEM768_CIPHERTEXT_SIZE = 1088;
27+
private static final int MLKEM768_SHARED_SECRET_SIZE = 32;
28+
private static final int X25519_KEY_SIZE = 32;
29+
30+
private byte[] mlkemPublicKey;
31+
private byte[] mlkemPrivateKeyEncoded;
32+
private byte[] x25519PublicKey;
33+
private byte[] x25519PrivateKey;
34+
35+
private byte[] mlkemSharedSecret;
36+
private byte[] x25519SharedSecret;
37+
private byte[] serverX25519PublicKey;
38+
private byte[] serverReply;
39+
private byte[] hybridSharedSecretK;
40+
41+
private Object kemInstance;
42+
43+
public MlKemHybridExchange() {
44+
super();
45+
}
46+
47+
@Override
48+
public void init(String name) throws IOException {
49+
if (!NAME.equals(name)) {
50+
throw new IOException("Invalid algorithm: " + name);
51+
}
52+
53+
try {
54+
KeyPairGenerator mlkemKpg = KeyPairGenerator.getInstance("ML-KEM-768");
55+
KeyPair mlkemKeyPair = mlkemKpg.generateKeyPair();
56+
byte[] x509Encoded = mlkemKeyPair.getPublic().getEncoded();
57+
mlkemPublicKey = extractRawMlKemPublicKey(x509Encoded);
58+
mlkemPrivateKeyEncoded = mlkemKeyPair.getPrivate().getEncoded();
59+
60+
if (mlkemPublicKey.length != MLKEM768_PUBLIC_KEY_SIZE) {
61+
throw new IOException(
62+
"Unexpected ML-KEM-768 public key size: "
63+
+ mlkemPublicKey.length
64+
+ " (expected "
65+
+ MLKEM768_PUBLIC_KEY_SIZE
66+
+ ")");
67+
}
68+
69+
x25519PrivateKey = X25519.generatePrivateKey();
70+
x25519PublicKey = X25519.publicFromPrivate(x25519PrivateKey);
71+
72+
if (x25519PublicKey.length != X25519_KEY_SIZE) {
73+
throw new IOException(
74+
"Unexpected X25519 public key size: "
75+
+ x25519PublicKey.length
76+
+ " (expected "
77+
+ X25519_KEY_SIZE
78+
+ ")");
79+
}
80+
81+
} catch (NoSuchAlgorithmException e) {
82+
throw new IOException("ML-KEM-768 or X25519 not available", e);
83+
} catch (InvalidKeyException e) {
84+
throw new IOException("Failed to generate key pair", e);
85+
}
86+
}
87+
88+
@Override
89+
public byte[] getE() {
90+
byte[] init = new byte[mlkemPublicKey.length + x25519PublicKey.length];
91+
System.arraycopy(mlkemPublicKey, 0, init, 0, mlkemPublicKey.length);
92+
System.arraycopy(
93+
x25519PublicKey, 0, init, mlkemPublicKey.length, x25519PublicKey.length);
94+
return init;
95+
}
96+
97+
@Override
98+
protected byte[] getServerE() {
99+
return serverReply != null ? serverReply.clone() : new byte[0];
100+
}
101+
102+
@Override
103+
public void setF(byte[] f) throws IOException {
104+
if (f.length != MLKEM768_CIPHERTEXT_SIZE + X25519_KEY_SIZE) {
105+
throw new IOException(
106+
"Invalid S_REPLY length: "
107+
+ f.length
108+
+ " (expected "
109+
+ (MLKEM768_CIPHERTEXT_SIZE + X25519_KEY_SIZE)
110+
+ ")");
111+
}
112+
113+
serverReply = f.clone();
114+
115+
try {
116+
byte[] mlkemCiphertext = new byte[MLKEM768_CIPHERTEXT_SIZE];
117+
System.arraycopy(f, 0, mlkemCiphertext, 0, MLKEM768_CIPHERTEXT_SIZE);
118+
119+
serverX25519PublicKey = new byte[X25519_KEY_SIZE];
120+
System.arraycopy(f, MLKEM768_CIPHERTEXT_SIZE, serverX25519PublicKey, 0, X25519_KEY_SIZE);
121+
122+
mlkemSharedSecret = performMlKemDecapsulation(mlkemCiphertext);
123+
124+
x25519SharedSecret = X25519.computeSharedSecret(x25519PrivateKey, serverX25519PublicKey);
125+
validateX25519SharedSecret(x25519SharedSecret);
126+
127+
byte[] combined = new byte[MLKEM768_SHARED_SECRET_SIZE + X25519_KEY_SIZE];
128+
System.arraycopy(mlkemSharedSecret, 0, combined, 0, MLKEM768_SHARED_SECRET_SIZE);
129+
System.arraycopy(x25519SharedSecret, 0, combined, MLKEM768_SHARED_SECRET_SIZE, X25519_KEY_SIZE);
130+
131+
hybridSharedSecretK = computeHybridSharedSecret(combined);
132+
sharedSecret = new BigInteger(1, hybridSharedSecretK);
133+
134+
} catch (InvalidKeyException e) {
135+
throw new IOException("X25519 key agreement failed", e);
136+
} catch (Exception e) {
137+
throw new IOException("ML-KEM decapsulation or key agreement failed", e);
138+
}
139+
}
140+
141+
private byte[] performMlKemDecapsulation(byte[] ciphertext) throws IOException {
142+
try {
143+
if (kemInstance == null) {
144+
Class<?> kemClass = Class.forName("javax.crypto.KEM");
145+
Method getInstance = kemClass.getMethod("getInstance", String.class);
146+
kemInstance = getInstance.invoke(null, "ML-KEM");
147+
}
148+
149+
KeyFactory kf = KeyFactory.getInstance("ML-KEM");
150+
PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(mlkemPrivateKeyEncoded);
151+
PrivateKey mlkemPrivateKey = kf.generatePrivate(privateKeySpec);
152+
153+
Class<?> kemClass = Class.forName("javax.crypto.KEM");
154+
Method newDecapsulator = kemClass.getMethod("newDecapsulator", PrivateKey.class);
155+
Object decapsulator = newDecapsulator.invoke(kemInstance, mlkemPrivateKey);
156+
157+
Class<?> decapsulatorClass = Class.forName("javax.crypto.KEM$Decapsulator");
158+
Method decapsulateMethod = decapsulatorClass.getMethod("decapsulate", byte[].class);
159+
Object secretKey = decapsulateMethod.invoke(decapsulator, ciphertext);
160+
161+
javax.crypto.SecretKey sk = (javax.crypto.SecretKey) secretKey;
162+
return sk.getEncoded();
163+
164+
} catch (ClassNotFoundException e) {
165+
throw new IOException("ML-KEM not available (Java 23+ required)", e);
166+
} catch (NoSuchAlgorithmException e) {
167+
throw new IOException("ML-KEM not available", e);
168+
} catch (Exception e) {
169+
throw new IOException("ML-KEM decapsulation failed", e);
170+
}
171+
}
172+
173+
private void validateX25519SharedSecret(byte[] sharedSecret) throws IOException {
174+
int allBytes = 0;
175+
for (int i = 0; i < sharedSecret.length; i++) {
176+
allBytes |= sharedSecret[i];
177+
}
178+
if (allBytes == 0) {
179+
throw new IOException("Invalid X25519 shared secret; all zeroes");
180+
}
181+
}
182+
183+
private byte[] computeHybridSharedSecret(byte[] combined) throws IOException {
184+
try {
185+
MessageDigest md = MessageDigest.getInstance("SHA-256");
186+
return md.digest(combined);
187+
} catch (NoSuchAlgorithmException e) {
188+
throw new IOException("SHA-256 not available", e);
189+
}
190+
}
191+
192+
@Override
193+
public String getHashAlgo() {
194+
return "SHA-256";
195+
}
196+
197+
@Override
198+
public byte[] getK() {
199+
if (hybridSharedSecretK == null) {
200+
throw new IllegalStateException("Shared secret not yet known, need f first!");
201+
}
202+
return hybridSharedSecretK.clone();
203+
}
204+
205+
private static byte[] extractRawMlKemPublicKey(byte[] x509Encoded) throws IOException {
206+
if (x509Encoded.length < 22) {
207+
throw new IOException("X.509 encoded ML-KEM public key too short");
208+
}
209+
210+
if (x509Encoded[0] != 0x30) {
211+
throw new IOException("Invalid X.509 encoding: expected SEQUENCE tag");
212+
}
213+
214+
if (x509Encoded[17] != 0x03) {
215+
throw new IOException("Invalid X.509 encoding: BIT STRING not found at expected position");
216+
}
217+
218+
if (x509Encoded[21] != 0x00) {
219+
throw new IOException("Invalid X.509 encoding: unexpected unused bits in BIT STRING");
220+
}
221+
222+
byte[] rawKey = new byte[MLKEM768_PUBLIC_KEY_SIZE];
223+
System.arraycopy(x509Encoded, 22, rawKey, 0, MLKEM768_PUBLIC_KEY_SIZE);
224+
return rawKey;
225+
}
226+
}

0 commit comments

Comments
 (0)