This is an automated email from the ASF dual-hosted git repository.

twolf pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/mina-sshd.git

commit 05311cac82b05ebcf30b580eeac1e3e7f343d72c
Author: Thomas Wolf <tw...@apache.org>
AuthorDate: Sun Feb 16 22:40:44 2025 +0100

    [SSHD-1161] Pubkey auth: handle certificates in PublickeyAuthenticator
    
    Move the responsibility of checking the user name against the
    certificate's principals into the PublickeyAuthenticator.
    
    First, if not, an "accept all" authenticator wouldn't work since the
    session might have rejected the certificate already.
    
    Second, the OpenSSH format allows the user to explicitly specify
    principals in the authorized_keys file. If so, these principals override
    the log-in user name.
---
 ...AuthorizedKeyEntriesPublickeyAuthenticator.java |  60 ++++++-
 .../auth/pubkey/CachingPublicKeyAuthenticator.java |   5 +-
 .../server/auth/pubkey/PublickeyAuthenticator.java |   9 +-
 .../sshd/server/auth/pubkey/UserAuthPublicKey.java |   5 -
 .../server/auth/AuthorizedKeysCertificateTest.java | 198 +++++++++++++++++++++
 5 files changed, 266 insertions(+), 11 deletions(-)

diff --git 
a/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/AuthorizedKeyEntriesPublickeyAuthenticator.java
 
b/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/AuthorizedKeyEntriesPublickeyAuthenticator.java
index 8650aa8f9..3feafc83c 100644
--- 
a/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/AuthorizedKeyEntriesPublickeyAuthenticator.java
+++ 
b/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/AuthorizedKeyEntriesPublickeyAuthenticator.java
@@ -26,10 +26,12 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Objects;
+import java.util.stream.Stream;
 
 import org.apache.sshd.common.AttributeRepository;
 import org.apache.sshd.common.config.keys.AuthorizedKeyEntry;
 import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.config.keys.OpenSshCertificate;
 import org.apache.sshd.common.config.keys.PublicKeyEntryResolver;
 import org.apache.sshd.common.util.GenericUtils;
 import org.apache.sshd.common.util.MapEntryUtils;
@@ -85,24 +87,76 @@ public class AuthorizedKeyEntriesPublickeyAuthenticator 
extends AbstractLoggingB
             return false;
         }
 
+        PublicKey keyToCheck = key;
+        boolean isCert = false;
+        if (key instanceof OpenSshCertificate) {
+            keyToCheck = ((OpenSshCertificate) key).getCaPubKey();
+            isCert = true;
+        }
         for (Map.Entry<AuthorizedKeyEntry, PublicKey> e : 
resolvedKeys.entrySet()) {
-            if (KeyUtils.compareKeys(key, e.getValue())) {
+            AuthorizedKeyEntry entry = e.getKey();
+            if (isCert == entry.getLoginOptions().containsKey("cert-authority")
+                    && KeyUtils.compareKeys(keyToCheck, e.getValue())) {
                 if (log.isDebugEnabled()) {
                     log.debug("authenticate({})[{}] match found", username, 
session);
                 }
+                // TODO: the entry might have an "expiry-time" option.
+                // See https://man.openbsd.org/sshd.8#expiry-time=_timespec_
+                // (Certificate expiration as stored in the certificate itself 
has been checked already.)
+                // TODO: the entry might have a "from" option limiting 
possible source addresses by IP, hostnames,
+                // patterns, or CIDRs.
+                // See https://man.openbsd.org/sshd.8#from=_pattern-list_
+                if (isCert && !matchesPrincipals(entry, username, 
(OpenSshCertificate) key, session)) {
+                    continue;
+                }
                 if (session != null) {
-                    session.setAttribute(AUTHORIZED_KEY, e.getKey());
+                    session.setAttribute(AUTHORIZED_KEY, entry);
                 }
                 return true;
             }
         }
 
         if (log.isDebugEnabled()) {
-            log.debug("authenticate({})[{}] match not found", username, 
session);
+            log.debug("authenticate({})[{}] no match found", username, 
session);
         }
         return false;
     }
 
+    protected boolean matchesPrincipals(
+            AuthorizedKeyEntry entry, String username, OpenSshCertificate cert,
+            ServerSession session) {
+        Collection<String> certPrincipals = cert.getPrincipals();
+        if (!GenericUtils.isEmpty(certPrincipals)) {
+            // "As a special case, a zero-length "valid principals" field 
means the certificate is valid for
+            // any principal of the specified type."
+            // See 
https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys
+            //
+            // This is true for user certificates unless they are checked via 
a TrustedUserCAKeys file, but
+            // that is not what we implement here.
+            // See https://man.openbsd.org/sshd_config#TrustedUserCAKeys
+            String allowedPrincipals = 
entry.getLoginOptions().get("principals");
+            if (!GenericUtils.isEmpty(allowedPrincipals)) {
+                if (Stream.of(allowedPrincipals.split(",")) //
+                        .map(String::trim) //
+                        .filter(s -> !GenericUtils.isEmpty(s)) //
+                        .noneMatch(certPrincipals::contains)) {
+                    log.debug("authenticate({})[{}] certificate match ignored, 
none of the allowed principals matched: {}",
+                            username, session, allowedPrincipals);
+                    return false;
+                }
+            } else {
+                // We have a match for the certificate, but no principals from 
the entry: check that given
+                // user name is in the certificate's principals.
+                if (!GenericUtils.isEmpty(certPrincipals) && 
!certPrincipals.contains(username)) {
+                    log.debug("authenticate({})[{}] certificate match 
rejected, user not in certificate principals: {}",
+                            username, session, username);
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+
     @Override
     public String toString() {
         return Objects.toString(getId());
diff --git 
a/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/CachingPublicKeyAuthenticator.java
 
b/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/CachingPublicKeyAuthenticator.java
index fb5e0f730..6fe798d55 100644
--- 
a/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/CachingPublicKeyAuthenticator.java
+++ 
b/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/CachingPublicKeyAuthenticator.java
@@ -26,6 +26,7 @@ import java.util.concurrent.ConcurrentHashMap;
 import org.apache.sshd.common.AttributeRepository;
 import org.apache.sshd.common.RuntimeSshException;
 import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.config.keys.OpenSshCertificate;
 import org.apache.sshd.common.util.logging.AbstractLoggingBean;
 import org.apache.sshd.server.session.ServerSession;
 
@@ -66,7 +67,9 @@ public class CachingPublicKeyAuthenticator extends 
AbstractLoggingBean implement
                 log.debug("authenticate({}@{}) cache result={} for {} key={}",
                         username, session, result, KeyUtils.getKeyType(key), 
KeyUtils.getFingerPrint(key));
             }
-            map.put(key, result);
+            if (!(key instanceof OpenSshCertificate)) {
+                map.put(key, result);
+            }
         } else {
             if (log.isDebugEnabled()) {
                 log.debug("authenticate({}@{}) use cached result={} for {} 
key={}",
diff --git 
a/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/PublickeyAuthenticator.java
 
b/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/PublickeyAuthenticator.java
index 45767f08c..703c5887d 100644
--- 
a/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/PublickeyAuthenticator.java
+++ 
b/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/PublickeyAuthenticator.java
@@ -38,12 +38,17 @@ import org.apache.sshd.server.session.ServerSession;
 public interface PublickeyAuthenticator {
 
     /**
-     * Check the validity of a public key.
+     * Checks whether the given {@link PublicKey} is allowed to be used for 
authenticating user "username" in a session.
+     * <p>
+     * Note that the {@code key} may be a {@link 
org.apache.sshd.common.config.keys.OpenSshCertificate}. A typical
+     * implementation for a certificate would check that the certificate's CA 
key is known to be trusted as a
+     * certificate authority, and that the given user name is listed in the 
certificate's principals.
+     * </p>
      *
      * @param  username           the username
      * @param  key                the key
      * @param  session            the server session
-     * @return                    a boolean indicating if authentication 
succeeded or not
+     * @return                    {@code true} if the key may be used; {@code 
false} otherwise
      * @throws AsyncAuthException If the authentication is performed 
asynchronously
      */
     boolean authenticate(String username, PublicKey key, ServerSession 
session) throws AsyncAuthException;
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 bc2ed93bb..04c0e8d28 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
@@ -103,11 +103,6 @@ public class UserAuthPublicKey extends AbstractUserAuth 
implements SignatureFact
                     throw new CertificateException("expired");
                 }
                 verifyCertificateSignature(session, cert);
-                // TODO: move this into the authenticator
-                Collection<String> principals = cert.getPrincipals();
-                if (!GenericUtils.isEmpty(principals) && 
!principals.contains(username)) {
-                    throw new CertificateException("not valid for the given 
username");
-                }
             } catch (Exception e) {
                 warn("doAuth({}@{}): public key certificate (id={}) is not 
valid: {}", username, session, cert.getId(),
                         e.getMessage(), e);
diff --git 
a/sshd-core/src/test/java/org/apache/sshd/server/auth/AuthorizedKeysCertificateTest.java
 
b/sshd-core/src/test/java/org/apache/sshd/server/auth/AuthorizedKeysCertificateTest.java
new file mode 100644
index 000000000..549e1f6ea
--- /dev/null
+++ 
b/sshd-core/src/test/java/org/apache/sshd/server/auth/AuthorizedKeysCertificateTest.java
@@ -0,0 +1,198 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+import org.apache.sshd.certificate.OpenSshCertificateBuilder;
+import org.apache.sshd.client.SshClient;
+import org.apache.sshd.client.auth.pubkey.UserAuthPublicKeyFactory;
+import org.apache.sshd.client.future.AuthFuture;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.SshException;
+import org.apache.sshd.common.config.keys.AuthorizedKeyEntry;
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.config.keys.OpenSshCertificate;
+import org.apache.sshd.common.config.keys.PublicKeyEntry;
+import org.apache.sshd.common.config.keys.PublicKeyEntryResolver;
+import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.server.SshServer;
+import org.apache.sshd.server.auth.keyboard.KeyboardInteractiveAuthenticator;
+import org.apache.sshd.server.auth.password.RejectAllPasswordAuthenticator;
+import 
org.apache.sshd.server.auth.pubkey.AuthorizedKeyEntriesPublickeyAuthenticator;
+import org.apache.sshd.util.test.BaseTestSupport;
+import org.apache.sshd.util.test.CommonTestSupportUtils;
+import org.apache.sshd.util.test.CoreTestSupportUtils;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests for user certificate authentication with checking an OpenSSH-style 
authorized_keys file.
+ */
+class AuthorizedKeysCertificateTest extends BaseTestSupport {
+
+    private static SshServer sshd;
+    private static SshClient client;
+    private static int port;
+
+    @TempDir
+    private Path tmp;
+
+    private KeyPair caKey;
+    private KeyPair userKey;
+    private OpenSshCertificate userCert;
+
+    @BeforeAll
+    private static void setupClientAndServer() throws Exception {
+        sshd = 
CoreTestSupportUtils.setupTestFullSupportServer(AuthorizedKeysCertificateTest.class);
+        sshd.start();
+        port = sshd.getPort();
+
+        client = 
CoreTestSupportUtils.setupTestFullSupportClient(AuthorizedKeysCertificateTest.class);
+        client.start();
+    }
+
+    @AfterAll
+    static void tearDownClientAndServer() throws Exception {
+        if (sshd != null) {
+            try {
+                sshd.stop(true);
+            } finally {
+                sshd = null;
+            }
+        }
+
+        if (client != null) {
+            try {
+                client.stop();
+            } finally {
+                client = null;
+            }
+        }
+    }
+
+    private static Stream<Arguments> options() {
+        return Stream.of(Arguments.of("", "", false), // Not CA: fail
+                Arguments.of("", "user", false), // Not CA: fail
+                Arguments.of("cert-authority", "user", true), // CA, 
principal=user: success
+                Arguments.of("cert-authority", "", true), // CA, no principal: 
success
+                Arguments.of("cert-authority", "other", false), // CA, wrong 
principal: fail
+                Arguments.of("cert-authority,principals=\"other\"", "user", 
false), // CA, principal overridden
+                Arguments.of("cert-authority,principals=\"other\"", "other", 
true) // CA, principal overridden
+        );
+    }
+
+    private void initCert(String caMarker, String... principals) throws 
Exception {
+        caKey = CommonTestSupportUtils.generateKeyPair(KeyUtils.RSA_ALGORITHM, 
2048);
+        userKey = 
CommonTestSupportUtils.generateKeyPair(KeyUtils.EC_ALGORITHM, 256);
+        // Generate a user certificate.
+        userCert = OpenSshCertificateBuilder.userCertificate() //
+                .serial(System.currentTimeMillis()) //
+                .publicKey(userKey.getPublic()) //
+                .id("test-cert") //
+                .validBefore(System.currentTimeMillis() + 
TimeUnit.HOURS.toMillis(1)) //
+                .principals(Arrays.asList(principals)) //
+                .sign(caKey, "rsa-sha2-256");
+        Path authorizedKeys = tmp.resolve("authorized_keys");
+        List<String> lines = new ArrayList<>();
+        StringBuilder line = new StringBuilder();
+        if (!GenericUtils.isEmpty(caMarker)) {
+            line.append(caMarker).append(' ');
+        }
+        line.append(PublicKeyEntry.toString(caKey.getPublic()));
+        lines.add(line.toString());
+        lines.add(PublicKeyEntry.toString(userKey.getPublic()));
+        Files.write(authorizedKeys, lines);
+
+        sshd.setPasswordAuthenticator(RejectAllPasswordAuthenticator.INSTANCE);
+        
sshd.setKeyboardInteractiveAuthenticator(KeyboardInteractiveAuthenticator.NONE);
+        sshd.setPublickeyAuthenticator(new 
AuthorizedKeyEntriesPublickeyAuthenticator(authorizedKeys, null,
+                AuthorizedKeyEntry.readAuthorizedKeys(authorizedKeys), 
PublicKeyEntryResolver.FAILING));
+        client.setUserAuthFactories(Collections.singletonList(new 
UserAuthPublicKeyFactory()));
+    }
+
+    @ParameterizedTest(name = "test {0} CA key with {1}")
+    @MethodSource("options")
+    void testCertificate(String marker, String principals, boolean 
expectSuccess) throws Exception {
+        String[] certPrincipals = GenericUtils.isEmpty(principals) ? new 
String[0] : principals.split(",");
+        initCert(marker, certPrincipals);
+        try (ClientSession s = client.connect("user", TEST_LOCALHOST, 
port).verify(CONNECT_TIMEOUT)
+                .getSession()) {
+            KeyPair certKeyPair = new KeyPair(userCert, userKey.getPrivate());
+            s.addPublicKeyIdentity(certKeyPair);
+            AuthFuture auth = s.auth();
+            if (expectSuccess) {
+                auth.verify(AUTH_TIMEOUT);
+            } else {
+                assertThrows(SshException.class, () -> 
auth.verify(AUTH_TIMEOUT));
+            }
+        }
+    }
+
+    private static Stream<Arguments> plainOptions() {
+        return Stream.of(Arguments.of("", true), // Not CA: success
+                Arguments.of("cert-authority", false) // CA: fail
+        );
+    }
+
+    private void initKeys(String marker) throws Exception {
+        caKey = CommonTestSupportUtils.generateKeyPair(KeyUtils.RSA_ALGORITHM, 
2048);
+        userKey = 
CommonTestSupportUtils.generateKeyPair(KeyUtils.EC_ALGORITHM, 256);
+        Path authorizedKeys = tmp.resolve("authorized_keys");
+        StringBuilder line = new StringBuilder();
+        if (!GenericUtils.isEmpty(marker)) {
+            line.append(marker).append(' ');
+        }
+        line.append(PublicKeyEntry.toString(userKey.getPublic()));
+        Files.write(authorizedKeys, 
Collections.singletonList(line.toString()));
+
+        sshd.setPasswordAuthenticator(RejectAllPasswordAuthenticator.INSTANCE);
+        
sshd.setKeyboardInteractiveAuthenticator(KeyboardInteractiveAuthenticator.NONE);
+        sshd.setPublickeyAuthenticator(new 
AuthorizedKeyEntriesPublickeyAuthenticator(authorizedKeys, null,
+                AuthorizedKeyEntry.readAuthorizedKeys(authorizedKeys), 
PublicKeyEntryResolver.FAILING));
+        client.setUserAuthFactories(Collections.singletonList(new 
UserAuthPublicKeyFactory()));
+    }
+
+    @ParameterizedTest(name = "test plain key with {0}")
+    @MethodSource("plainOptions")
+    void testPlainKey(String marker, boolean expectSuccess) throws Exception {
+        initKeys(marker);
+        try (ClientSession s = client.connect("user", TEST_LOCALHOST, 
port).verify(CONNECT_TIMEOUT).getSession()) {
+            s.addPublicKeyIdentity(userKey);
+            AuthFuture auth = s.auth();
+            if (expectSuccess) {
+                auth.verify(AUTH_TIMEOUT);
+            } else {
+                assertThrows(SshException.class, () -> 
auth.verify(AUTH_TIMEOUT));
+            }
+        }
+    }
+}

Reply via email to