Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ testing {
useJUnitJupiter()
dependencies {
implementation "org.slf4j:slf4j-api:2.0.17"
implementation 'org.spockframework:spock-core:2.3-groovy-3.0'
implementation 'org.apache.groovy:groovy:4.0.30'
implementation 'org.spockframework:spock-core:2.3-groovy-4.0'
implementation "org.mockito:mockito-core:5.16.1"
implementation "org.assertj:assertj-core:3.27.3"
implementation "ru.vyarus:spock-junit5:1.2.0"
Expand Down
15 changes: 14 additions & 1 deletion src/itest/java/com/hierynomus/sshj/SshdContainer.java
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,12 @@ public static SshdConfigBuilder defaultBuilder() {
}

public static class Builder implements Consumer<DockerfileBuilder> {
private static final String DEFAULT_BASE_IMAGE = "alpine:3.19.0";

private List<String> hostKeys = new ArrayList<>();
private List<String> certificates = new ArrayList<>();
private @NotNull SshdConfigBuilder sshdConfig = SshdConfigBuilder.defaultBuilder();
private @NotNull String baseImage = DEFAULT_BASE_IMAGE;

public static Builder defaultBuilder() {
Builder b = new Builder();
Expand All @@ -119,6 +122,16 @@ public static Builder defaultBuilder() {
return this;
}

/**
* Override the base image used to build the sshd container. Useful for tests that need
* a specific OpenSSH version (for example, OpenSSH&nbsp;≥10 for the
* {@code mlkem768x25519-sha256} key exchange).
*/
public @NotNull Builder withBaseImage(@NotNull String baseImage) {
this.baseImage = baseImage;
return this;
}

public @NotNull Builder withAllKeys() {
this.addHostKey("test-container/ssh_host_ecdsa_key");
this.addHostKey("test-container/ssh_host_ed25519_key");
Expand Down Expand Up @@ -148,7 +161,7 @@ public static Builder defaultBuilder() {

@Override
public void accept(@NotNull DockerfileBuilder builder) {
builder.from("alpine:3.19.0");
builder.from(baseImage);
builder.run("apk add --no-cache openssh");
builder.expose(22);
builder.copy("entrypoint.sh", "/entrypoint.sh");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright (C)2009 - SSHJ Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hierynomus.sshj.transport.kex;

import com.hierynomus.sshj.SshdContainer;
import com.hierynomus.sshj.SshdContainer.SshdConfigBuilder;
import net.schmizz.sshj.Config;
import net.schmizz.sshj.DefaultConfig;
import net.schmizz.sshj.SSHClient;
import net.schmizz.sshj.transport.kex.MLKEM768X25519SHA256;
import net.schmizz.sshj.transport.verification.PromiscuousVerifier;
import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.util.Collections;
import java.util.concurrent.atomic.AtomicReference;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Verifies interop with a real OpenSSH server (10.x) for the post-quantum hybrid key
* exchange {@code mlkem768x25519-sha256}.
*
* <p>The container is built on Alpine&nbsp;3.22, whose {@code openssh} package is
* 10.0p1 — the first OpenSSH release that ships {@code mlkem768x25519-sha256} (it is
* the default KEX in 10.x). The {@link SshdConfigBuilder} {@code KexAlgorithms} line is
* replaced with one containing only {@code mlkem768x25519-sha256} to ensure negotiation
* cannot fall through to a classical KEX.</p>
*/
@Testcontainers
public class MLKEMHybridKexIntegrationTest {

private static final String OPENSSH_10_BASE_IMAGE = "alpine:3.22";
private static final String HYBRID_KEX_NAME = "mlkem768x25519-sha256";

/**
* sshd_config without a {@code KexAlgorithms} line. Required because in
* {@code sshd_config} the first occurrence of an option wins, so we cannot simply
* append our hybrid-only line on top of {@link SshdConfigBuilder#DEFAULT_SSHD_CONFIG}
* (which already declares a classical-only {@code KexAlgorithms}). We then add the
* hybrid line via {@link SshdConfigBuilder#with(String, String)}.
*/
private static final String SSHD_CONFIG_NO_KEX = ""
+ "PermitRootLogin yes\n"
+ "AuthorizedKeysFile .ssh/authorized_keys\n"
+ "Subsystem sftp /usr/lib/ssh/sftp-server\n"
+ "macs hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256,hmac-sha2-512\n"
+ "TrustedUserCAKeys /etc/ssh/trusted_ca_keys\n"
+ "Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com\n"
+ "LogLevel DEBUG2\n";

@Container
private static final SshdContainer sshd = SshdContainer.Builder.defaultBuilder()
.withBaseImage(OPENSSH_10_BASE_IMAGE)
.withSshdConfig(new SshdConfigBuilder(SSHD_CONFIG_NO_KEX)
.with("KexAlgorithms", HYBRID_KEX_NAME))
.withAllKeys()
.build();

@Test
public void shouldNegotiateMlkem768X25519Sha256WithOpenSsh10() throws Throwable {
final Config config = new DefaultConfig();
// Force sshj to offer ONLY the hybrid KEX so the assertion below cannot pass by
// falling back to a classical one.
config.setKeyExchangeFactories(Collections.singletonList(new MLKEM768X25519SHA256.Factory()));

final AtomicReference<String> negotiatedKex = new AtomicReference<>();
try (SSHClient client = new SSHClient(config)) {
client.addHostKeyVerifier(new PromiscuousVerifier());
client.addAlgorithmsVerifier(algorithms -> {
negotiatedKex.set(algorithms.getKeyExchangeAlgorithm());
return true;
});
client.connect("127.0.0.1", sshd.getFirstMappedPort());

client.authPublickey("sshj", "src/itest/resources/keyfiles/id_rsa_opensshv1");
assertTrue(client.isAuthenticated(), "public-key auth should succeed over the hybrid KEX");
}

assertEquals(HYBRID_KEX_NAME, negotiatedKex.get(),
"transport must have negotiated mlkem768x25519-sha256 with the OpenSSH 10 server");
}
}
12 changes: 10 additions & 2 deletions src/main/java/net/schmizz/sshj/DefaultConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
import net.schmizz.sshj.transport.kex.DHGexSHA1;
import net.schmizz.sshj.transport.kex.DHGexSHA256;
import net.schmizz.sshj.transport.kex.ECDHNistP;
import net.schmizz.sshj.transport.kex.KeyExchange;
import net.schmizz.sshj.transport.kex.MLKEM768X25519SHA256;
import net.schmizz.sshj.transport.random.JCERandom;
import net.schmizz.sshj.transport.random.SingletonRandomFactory;
import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile;
Expand All @@ -43,6 +45,7 @@
import org.slf4j.Logger;

import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.LinkedList;
import java.util.ListIterator;
Expand Down Expand Up @@ -104,7 +107,11 @@ public void setLoggerFactory(LoggerFactory loggerFactory) {
}

protected void initKeyExchangeFactories() {
setKeyExchangeFactories(
final List<Factory.Named<KeyExchange>> factories = new ArrayList<>();
if (MLKEM768X25519SHA256.isSupported()) {
factories.add(new MLKEM768X25519SHA256.Factory());
}
factories.addAll(Arrays.<Factory.Named<KeyExchange>>asList(
new Curve25519SHA256.Factory(),
new Curve25519SHA256.FactoryLibSsh(),
new DHGexSHA256.Factory(),
Expand All @@ -128,7 +135,8 @@ protected void initKeyExchangeFactories() {
ExtendedDHGroups.Group16SHA512AtSSH(),
ExtendedDHGroups.Group18SHA512AtSSH(),
new ExtInfoClientFactory()
);
));
setKeyExchangeFactories(factories);
}

protected void initKeyAlgorithms() {
Expand Down
179 changes: 179 additions & 0 deletions src/main/java/net/schmizz/sshj/common/BouncyCastleKEM.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/*
* Copyright (C)2009 - SSHJ Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.schmizz.sshj.common;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.security.GeneralSecurityException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.SecureRandom;

/**
* Implementation of {@link SshjKEM} backed by Bouncy Castle's lightweight ML-KEM API
* ({@code org.bouncycastle.pqc.crypto.mlkem}), accessed entirely via reflection so
* that this class compiles, loads and verifies even when Bouncy Castle is absent
* from the runtime classpath (e.g. shaded out by a downstream consumer).
*
* <p>This is a fallback used by {@link SecurityUtils#getKEM(String)} when the JDK
* 21+ {@code javax.crypto.KEM} API is not available (i.e. on Java 8&ndash;20).
* Callers should query {@link #isAvailable()} first.</p>
*/
final class BouncyCastleKEM implements SshjKEM {

/** BC ML-KEM family name (parameter set inferred from the encoded key). */
private static final String ML_KEM = "ML-KEM";

/**
* Shared {@link SecureRandom}. {@code SecureRandom} is documented thread-safe, and
* lazily seeded by the JDK on first use, so a single instance avoids paying the
* (potentially blocking) seed cost on every encapsulation.
*/
private static final SecureRandom SECURE_RANDOM = new SecureRandom();

private static final boolean AVAILABLE;
private static final Constructor<?> GENERATOR_CTOR;
private static final Method GENERATE_ENCAPSULATED;
private static final Method GET_ENCAPSULATION;
private static final Method GET_SECRET;
private static final Method DESTROY;
private static final Constructor<?> EXTRACTOR_CTOR;
private static final Class<?> MLKEM_PRIVATE_KEY_PARAMETERS;
private static final Method EXTRACT_SECRET;
private static final Method PUBLIC_KEY_FACTORY_CREATE;
private static final Method PRIVATE_KEY_FACTORY_CREATE;

static {
boolean available = false;
Constructor<?> generatorCtor = null;
Method generateEncapsulated = null;
Method getEncapsulation = null;
Method getSecret = null;
Method destroy = null;
Constructor<?> extractorCtor = null;
Class<?> mlkemPrivateKeyParameters = null;
Method extractSecret = null;
Method publicKeyFactoryCreate = null;
Method privateKeyFactoryCreate = null;
try {
Class<?> generator = Class.forName("org.bouncycastle.pqc.crypto.mlkem.MLKEMGenerator");
Class<?> extractor = Class.forName("org.bouncycastle.pqc.crypto.mlkem.MLKEMExtractor");
mlkemPrivateKeyParameters = Class.forName("org.bouncycastle.pqc.crypto.mlkem.MLKEMPrivateKeyParameters");
Class<?> asymmetricKeyParameter = Class.forName("org.bouncycastle.crypto.params.AsymmetricKeyParameter");
Class<?> secretWithEncapsulation = Class.forName("org.bouncycastle.crypto.SecretWithEncapsulation");
Class<?> publicKeyFactory = Class.forName("org.bouncycastle.pqc.crypto.util.PublicKeyFactory");
Class<?> privateKeyFactory = Class.forName("org.bouncycastle.pqc.crypto.util.PrivateKeyFactory");

generatorCtor = generator.getConstructor(SecureRandom.class);
generateEncapsulated = generator.getMethod("generateEncapsulated", asymmetricKeyParameter);
getEncapsulation = secretWithEncapsulation.getMethod("getEncapsulation");
getSecret = secretWithEncapsulation.getMethod("getSecret");
destroy = secretWithEncapsulation.getMethod("destroy");
extractorCtor = extractor.getConstructor(mlkemPrivateKeyParameters);
extractSecret = extractor.getMethod("extractSecret", byte[].class);
publicKeyFactoryCreate = publicKeyFactory.getMethod("createKey", byte[].class);
privateKeyFactoryCreate = privateKeyFactory.getMethod("createKey", byte[].class);

available = true;
} catch (Throwable t) {
// Bouncy Castle PQC absent or incompatible: fallback unavailable.
}
AVAILABLE = available;
GENERATOR_CTOR = generatorCtor;
GENERATE_ENCAPSULATED = generateEncapsulated;
GET_ENCAPSULATION = getEncapsulation;
GET_SECRET = getSecret;
DESTROY = destroy;
EXTRACTOR_CTOR = extractorCtor;
MLKEM_PRIVATE_KEY_PARAMETERS = mlkemPrivateKeyParameters;
EXTRACT_SECRET = extractSecret;
PUBLIC_KEY_FACTORY_CREATE = publicKeyFactoryCreate;
PRIVATE_KEY_FACTORY_CREATE = privateKeyFactoryCreate;
}

static boolean isAvailable() {
return AVAILABLE;
}

static BouncyCastleKEM create(String algorithm) throws NoSuchAlgorithmException {
if (!AVAILABLE) {
throw new NoSuchAlgorithmException(
"Bouncy Castle PQC is not available; cannot fall back from javax.crypto.KEM");
}
if (!ML_KEM.equals(algorithm)) {
throw new NoSuchAlgorithmException(
"Bouncy Castle KEM fallback only supports " + ML_KEM + ", requested " + algorithm);
}
return new BouncyCastleKEM();
}

private BouncyCastleKEM() {
}

@Override
public Encapsulated encapsulate(PublicKey peerPublicKey) throws GeneralSecurityException {
try {
Object params = PUBLIC_KEY_FACTORY_CREATE.invoke(null, (Object) peerPublicKey.getEncoded());
Object generator = GENERATOR_CTOR.newInstance(SECURE_RANDOM);
Object result = GENERATE_ENCAPSULATED.invoke(generator, params);
try {
byte[] ciphertext = (byte[]) GET_ENCAPSULATION.invoke(result);
byte[] sharedSecret = (byte[]) GET_SECRET.invoke(result);
return new Encapsulated(ciphertext, sharedSecret);
} finally {
try {
DESTROY.invoke(result);
} catch (Throwable ignore) {
// best-effort wipe
}
}
} catch (InvocationTargetException ite) {
throw rethrow(ite, "Failed to encapsulate via Bouncy Castle");
} catch (ReflectiveOperationException roe) {
throw new GeneralSecurityException("Failed to invoke Bouncy Castle ML-KEM API", roe);
}
}

@Override
public byte[] decapsulate(PrivateKey ourPrivateKey, byte[] ciphertext) throws GeneralSecurityException {
try {
Object params = PRIVATE_KEY_FACTORY_CREATE.invoke(null, (Object) ourPrivateKey.getEncoded());
if (!MLKEM_PRIVATE_KEY_PARAMETERS.isInstance(params)) {
throw new GeneralSecurityException(
"Expected ML-KEM private key but got " + params.getClass().getName());
}
Object extractor = EXTRACTOR_CTOR.newInstance(params);
return (byte[]) EXTRACT_SECRET.invoke(extractor, (Object) ciphertext);
} catch (InvocationTargetException ite) {
throw rethrow(ite, "Failed to decapsulate via Bouncy Castle");
} catch (ReflectiveOperationException roe) {
throw new GeneralSecurityException("Failed to invoke Bouncy Castle ML-KEM API", roe);
}
}

private static GeneralSecurityException rethrow(InvocationTargetException ite, String message) {
Throwable cause = ite.getCause();
if (cause instanceof GeneralSecurityException) {
return (GeneralSecurityException) cause;
}
if (cause instanceof RuntimeException) {
throw (RuntimeException) cause;
}
return new GeneralSecurityException(message, cause);
}
}
Loading