This is an automated email from the ASF dual-hosted git repository. tomaswolf pushed a commit to branch rc-2.19.0 in repository https://gitbox.apache.org/repos/asf/mina-sshd.git
commit 333c3c04bb34910b804f87233cc8e4b9a4583ba4 Author: Thomas Wolf <[email protected]> AuthorDate: Thu Jun 4 13:02:51 2026 +0200 Fix server-side user certificate handling Complete checking critical options present in a certificate, and add a unit test. --- .../common/config/keys/OpenSshCertificate.java | 16 ++ .../sshd/server/auth/pubkey/UserAuthPublicKey.java | 22 +- .../auth/UserAuthPublicKeyCertificateTest.java | 234 +++++++++++++++++++++ 3 files changed, 270 insertions(+), 2 deletions(-) diff --git a/sshd-common/src/main/java/org/apache/sshd/common/config/keys/OpenSshCertificate.java b/sshd-common/src/main/java/org/apache/sshd/common/config/keys/OpenSshCertificate.java index b18cb5d01..1691b2614 100644 --- a/sshd-common/src/main/java/org/apache/sshd/common/config/keys/OpenSshCertificate.java +++ b/sshd-common/src/main/java/org/apache/sshd/common/config/keys/OpenSshCertificate.java @@ -76,6 +76,22 @@ public interface OpenSshCertificate extends SshPublicKey, PrivateKey { */ long INFINITY = 0xffff_ffff_ffff_ffffL; + /** + * Critical option. If present, a signature made with the certified key must include the corresponding assertion. + */ + String VERIFY_REQUIRED = "verify-required"; + + /** + * Critical option: if present a string value giving the forced command for "exec" or "shell" channel sessions, or + * for subsystems. + */ + String FORCE_COMMAND = "force-command"; + + /** + * Critical option. Comma-separated list of CIDRs for allowed peers. + */ + String SOURCE_ADDRESS = "source-address"; + /** * Retrieves the raw SSH key type of this certificate. * diff --git a/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/UserAuthPublicKey.java b/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/UserAuthPublicKey.java index a1f07629d..9b4b4b40d 100644 --- a/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/UserAuthPublicKey.java +++ b/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/UserAuthPublicKey.java @@ -26,6 +26,7 @@ import java.security.SignatureException; import java.security.cert.CertificateException; import java.util.Collection; import java.util.List; +import java.util.Map; import org.apache.sshd.common.NamedFactory; import org.apache.sshd.common.NamedResource; @@ -110,7 +111,7 @@ public class UserAuthPublicKey extends AbstractUserAuth implements SignatureFact throw new CertificateException("expired"); } verifyCertificateSignature(session, cert); - verifyCertificateSources(session, cert); + verifyCriticalOptions(session, cert); } catch (Exception e) { warn("doAuth({}@{}): public key certificate (id={}) is not valid: {}", username, session, cert.getId(), e.getMessage(), e); @@ -217,8 +218,25 @@ public class UserAuthPublicKey extends AbstractUserAuth implements SignatureFact } } + protected void verifyCriticalOptions(ServerSession session, OpenSshCertificate cert) throws CertificateException { + Map<String, String> criticalOptions = cert.getCriticalOptionsMap(); + for (String option : criticalOptions.keySet()) { + if (OpenSshCertificate.VERIFY_REQUIRED.equals(option)) { + throw new CertificateException( + "Rejected by verify-required critical option: not implemented yet, have certificate type " + + cert.getKeyType()); + } else if (OpenSshCertificate.SOURCE_ADDRESS.equals(option)) { + verifyCertificateSources(session, cert); + } else if (OpenSshCertificate.FORCE_COMMAND.equals(option)) { + throw new CertificateException("Rejected by force-command critical option: not implemented yet"); + } else { + throw new CertificateException("Rejected by unknown critical option " + option); + } + } + } + protected void verifyCertificateSources(ServerSession session, OpenSshCertificate cert) throws CertificateException { - String allowedSources = cert.getCriticalOptionsMap().get("source-address"); + String allowedSources = cert.getCriticalOptionsMap().get(OpenSshCertificate.SOURCE_ADDRESS); if (allowedSources == null) { return; } diff --git a/sshd-core/src/test/java/org/apache/sshd/server/auth/UserAuthPublicKeyCertificateTest.java b/sshd-core/src/test/java/org/apache/sshd/server/auth/UserAuthPublicKeyCertificateTest.java new file mode 100644 index 000000000..066e15518 --- /dev/null +++ b/sshd-core/src/test/java/org/apache/sshd/server/auth/UserAuthPublicKeyCertificateTest.java @@ -0,0 +1,234 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sshd.server.auth; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.SignatureException; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.ThreadLocalRandom; + +import org.apache.sshd.certificate.OpenSshCertificateBuilder; +import org.apache.sshd.common.Factory; +import org.apache.sshd.common.SshConstants; +import org.apache.sshd.common.auth.AbstractUserAuthServiceFactory; +import org.apache.sshd.common.config.keys.OpenSshCertificate; +import org.apache.sshd.common.io.IoSession; +import org.apache.sshd.common.random.JceRandomFactory; +import org.apache.sshd.common.random.Random; +import org.apache.sshd.common.random.SingletonRandomFactory; +import org.apache.sshd.common.signature.BuiltinSignatures; +import org.apache.sshd.common.signature.Signature; +import org.apache.sshd.common.util.buffer.BufferUtils; +import org.apache.sshd.common.util.buffer.ByteArrayBuffer; +import org.apache.sshd.common.util.security.SecurityUtils; +import org.apache.sshd.server.ServerFactoryManager; +import org.apache.sshd.server.auth.pubkey.AcceptAllPublickeyAuthenticator; +import org.apache.sshd.server.auth.pubkey.UserAuthPublicKey; +import org.apache.sshd.server.session.ServerSessionImpl; +import org.apache.sshd.util.test.BaseTestSupport; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mockito; + +/** + * Unit test for {@link UserAuthPublickey} handling certificates on the server-side. + */ +@Tag("NoIoTestCase") +class UserAuthPublicKeyCertificateTest extends BaseTestSupport { + + @ParameterizedTest(name = "auth {0} {1}") + @CsvSource({ // + ",,true", // + "source-address,10.1.2.3/32,true", // + "source-address,10.1/16,true", // + "source-address,'10.2/16,10.1/16', true", // + "source-address,10.2/16,false", // + "source-address,10.1.2.1/32,false", // + "source-address,172.1/16,false", // + "verify-required,,false", // + "verify-required,bogus,false", // + "force-command,,false", // + "force-command,exit,false", // + "unknown-option,,false", // + "unknown-option,unknown,false" // + }) + void testCertOptions(String criticalOption, String optionValue, boolean expectSuccess) throws Exception { + KeyPairGenerator generator = SecurityUtils.getKeyPairGenerator("EC"); + generator.initialize(256); + KeyPair pair = generator.generateKeyPair(); + + // Create a CA key + KeyPair caKeypair = generator.generateKeyPair(); + // Create a certificate for the above key + OpenSshCertificateBuilder builder = OpenSshCertificateBuilder.userCertificate() // + .serial(0L) // + .publicKey(pair.getPublic()) // + .id("testuser") // + .principals(Collections.singletonList("testuser")); + if (criticalOption != null && !criticalOption.isEmpty()) { + builder = builder.criticalOption(criticalOption, optionValue); + } + OpenSshCertificate cert = builder.sign(caKeypair); + + MockSession session = createSession(); + // Give it a session ID since it is part of the signed data. + byte[] id = new byte[32]; + ThreadLocalRandom.current().nextBytes(id); + session.setSessionId(id); + // We don't care about the authenticator in this test. + session.setPublickeyAuthenticator(AcceptAllPublickeyAuthenticator.INSTANCE); + // Create the UserAuthPublickey object under test; give it the signature factories we need. + UserAuthPublicKey pubkeyAuth = new UserAuthPublicKey( + Arrays.asList(BuiltinSignatures.nistp256_cert, BuiltinSignatures.nistp256)); + + // Create a buffer with a full properly signed authentication request. + ByteArrayBuffer buffer = createRequest(id, cert, pair.getPrivate()); + + String userName = buffer.getString(); + String serviceName = buffer.getString(); + buffer.getString(); // Skip method name + + // Finally try to authenticate and check the result. + try { + Boolean result = pubkeyAuth.auth(session, userName, serviceName, buffer); + assertEquals(expectSuccess, result.booleanValue()); + } catch (SignatureException e) { + if (!"Key verification failed".equals(e.getMessage())) { + throw e; + } + assertFalse(expectSuccess); + } + } + + @ParameterizedTest(name = "auth {0} {1}") + @CsvSource({ // + ",,true", // + "verify-required,,false", // + "verify-required,bogus,false", // + "force-command,,false", // + "force-command,exit,false", // + "unknown-option,,false", // + "unknown-option,unknown,false" // + }) + void testMultipleCertOptions(String criticalOption, String optionValue, boolean expectSuccess) throws Exception { + KeyPairGenerator generator = SecurityUtils.getKeyPairGenerator("EC"); + generator.initialize(256); + KeyPair pair = generator.generateKeyPair(); + + // Create a CA key + KeyPair caKeypair = generator.generateKeyPair(); + // Create a certificate for the above key + OpenSshCertificateBuilder builder = OpenSshCertificateBuilder.userCertificate() // + .serial(0L) // + .publicKey(pair.getPublic()) // + .id("testuser") // + .principals(Collections.singletonList("testuser")); + builder.criticalOption(OpenSshCertificate.SOURCE_ADDRESS, "10.1.2.3/24"); + if (criticalOption != null && !criticalOption.isEmpty()) { + builder = builder.criticalOption(criticalOption, optionValue); + } + OpenSshCertificate cert = builder.sign(caKeypair); + + MockSession session = createSession(); + // Give it a session ID since it is part of the signed data. + byte[] id = new byte[32]; + ThreadLocalRandom.current().nextBytes(id); + session.setSessionId(id); + // We don't care about the authenticator in this test. + session.setPublickeyAuthenticator(AcceptAllPublickeyAuthenticator.INSTANCE); + // Create the UserAuthPublickey object under test; give it the signature factories we need. + UserAuthPublicKey pubkeyAuth = new UserAuthPublicKey( + Arrays.asList(BuiltinSignatures.nistp256_cert, BuiltinSignatures.nistp256)); + + // Create a buffer with a full properly signed authentication request. + ByteArrayBuffer buffer = createRequest(id, cert, pair.getPrivate()); + + String userName = buffer.getString(); + String serviceName = buffer.getString(); + buffer.getString(); // Skip method name + + // Finally try to authenticate and check the result. + try { + Boolean result = pubkeyAuth.auth(session, userName, serviceName, buffer); + assertEquals(expectSuccess, result.booleanValue()); + } catch (SignatureException e) { + if (!"Key verification failed".equals(e.getMessage())) { + throw e; + } + assertFalse(expectSuccess); + } + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private MockSession createSession() throws Exception { + // Create a mock ServerSession. We can't simply mock the server session since the authenticator might want to + // set an attribute on it. + ServerFactoryManager manager = Mockito.mock(ServerFactoryManager.class); + Factory<? extends Random> randomFactory = new SingletonRandomFactory(JceRandomFactory.INSTANCE); + Mockito.when(manager.getRandomFactory()).thenReturn((Factory) randomFactory); + IoSession ioSession = Mockito.mock(IoSession.class); + InetSocketAddress fakeClientAddress = new InetSocketAddress(InetAddress.getByAddress(new byte[] { 10, 1, 2, 3 }), 1234); + Mockito.when(ioSession.getRemoteAddress()).thenReturn(fakeClientAddress); + return new MockSession(manager, ioSession); + } + + private ByteArrayBuffer createRequest(byte[] sessionId, OpenSshCertificate cert, PrivateKey priv) + throws Exception { + ByteArrayBuffer payload = new ByteArrayBuffer(); + payload.putString("testuser"); + payload.putString(AbstractUserAuthServiceFactory.DEFAULT_NAME); + payload.putString(UserAuthPublicKey.NAME); + payload.putBoolean(true); // With signature + payload.putString(cert.getKeyType()); // Algorithm + payload.putPublicKey(cert); + + Signature signer = BuiltinSignatures.nistp256_cert.create(); + signer.initSigner(null, priv); + byte[] uint = new byte[4]; + BufferUtils.putUInt(sessionId.length, uint); + signer.update(null, uint); + signer.update(null, sessionId); + signer.update(null, new byte[] { SshConstants.SSH_MSG_USERAUTH_REQUEST }); + signer.update(null, payload.getCompactData()); + byte[] rawSignature = signer.sign(null); + + ByteArrayBuffer sig = new ByteArrayBuffer(); + sig.putString(cert.getRawKeyType()); + sig.putBytes(rawSignature); + payload.putBytes(sig.getCompactData()); + return payload; + } + + private static class MockSession extends ServerSessionImpl { + + MockSession(ServerFactoryManager server, IoSession ioSession) throws Exception { + super(server, ioSession); + } + + void setSessionId(byte[] id) { + sessionId = id.clone(); + } + } +}
