diff --git a/src/main/java/com/github/stefanbirkner/fakesftpserver/rule/FakeSftpServerRule.java b/src/main/java/com/github/stefanbirkner/fakesftpserver/rule/FakeSftpServerRule.java index f8d0a41..af6008c 100644 --- a/src/main/java/com/github/stefanbirkner/fakesftpserver/rule/FakeSftpServerRule.java +++ b/src/main/java/com/github/stefanbirkner/fakesftpserver/rule/FakeSftpServerRule.java @@ -1,29 +1,44 @@ package com.github.stefanbirkner.fakesftpserver.rule; -import org.apache.sshd.server.SshServer; -import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider; -import org.apache.sshd.server.session.ServerSession; -import org.apache.sshd.server.subsystem.sftp.SftpSubsystemFactory; -import org.junit.rules.TestRule; -import org.junit.runner.Description; -import org.junit.runners.model.Statement; +import static com.github.marschall.memoryfilesystem.MemoryFileSystemBuilder.newLinux; +import static java.nio.file.FileVisitResult.CONTINUE; +import static java.nio.file.Files.copy; +import static java.nio.file.Files.delete; +import static java.nio.file.Files.exists; +import static java.nio.file.Files.isDirectory; +import static java.nio.file.Files.readAllBytes; +import static java.nio.file.Files.walkFileTree; +import static java.nio.file.Files.write; +import static java.util.Collections.singletonList; import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; -import java.nio.file.*; +import java.nio.file.FileStore; +import java.nio.file.FileSystem; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.WatchService; import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.UserPrincipalLookupService; import java.nio.file.spi.FileSystemProvider; +import java.security.PublicKey; import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.Set; -import static com.github.marschall.memoryfilesystem.MemoryFileSystemBuilder.newLinux; -import static java.nio.file.FileVisitResult.CONTINUE; -import static java.nio.file.Files.*; -import static java.util.Collections.singletonList; +import org.apache.sshd.server.SshServer; +import org.apache.sshd.server.config.keys.DefaultAuthorizedKeysAuthenticator; +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider; +import org.apache.sshd.server.session.ServerSession; +import org.apache.sshd.server.subsystem.sftp.SftpSubsystemFactory; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; /** * Fake SFTP Server Rule is a JUnit rule that runs an in-memory SFTP server @@ -178,6 +193,7 @@ public FileVisitResult postVisitDirectory( } }; private final Map usernamesAndPasswords = new HashMap<>(); + private final Map usernamesAndIdentities = new HashMap<>(); private int port = 0; private FileSystem fileSystem; @@ -245,6 +261,27 @@ public FakeSftpServerRule addUser( return this; } + /** + * Register a username with its identity key and password. After registering a username + * it is only possible to connect to the server with one of the registered + * username/password or username/identity pairs. + *

If {@code addIdentity} is called multiple times with the same username but + * different keys then the last key is effective.

+ *

This method is compatible with {@code addUser} meaning if you call + * both then the last username/password is effective and the last + * username/identity is effective.

+ * @param username the username. + * @param identityPath path to identity file (e.g. authorized_keys file). + * @return the rule itself. + */ + public FakeSftpServerRule addIdentity( + String username, + Path identityPath + ) { + usernamesAndIdentities.put(username, identityPath); + return this; + } + private void restartServer() { try { server.stop(); @@ -429,6 +466,7 @@ private SshServer startServer( server.setPort(port); server.setKeyPairProvider(new SimpleGeneratorHostKeyProvider()); server.setPasswordAuthenticator(this::authenticate); + server.setPublickeyAuthenticator(this::authenticatePublicKey); server.setSubsystemFactories(singletonList(new SftpSubsystemFactory())); /* When a channel is closed SshServer calls close() on the file system. * In order to use the file system for multiple channels/sessions we @@ -439,18 +477,39 @@ private SshServer startServer( this.server = server; return server; } + + private boolean emptySecurity() { + return usernamesAndPasswords.isEmpty() && usernamesAndIdentities.isEmpty(); + } private boolean authenticate( String username, String password, ServerSession session ) { - return usernamesAndPasswords.isEmpty() + return emptySecurity() || Objects.equals( usernamesAndPasswords.get(username), password ); } + + private boolean authenticatePublicKey( + String username, + PublicKey publicKey, + ServerSession session + ) { + if (emptySecurity()) { + return true; + } else if (!usernamesAndIdentities.containsKey(username)) { + return false; + } + Path path = usernamesAndIdentities.get(username); + // don't load authorized keys in strict mode + // strict mode forces checks on 'authorized_keys' files for security + // but this is a test rule and CI builders might not force permissions + return new DefaultAuthorizedKeysAuthenticator(username, path, false).authenticate(username, publicKey, session); + } private void ensureDirectoryOfPathExists( Path path diff --git a/src/test/java/com/github/stefanbirkner/fakesftpserver/rule/FakeSftpServerRuleTest.java b/src/test/java/com/github/stefanbirkner/fakesftpserver/rule/FakeSftpServerRuleTest.java index b337070..f8e6047 100644 --- a/src/test/java/com/github/stefanbirkner/fakesftpserver/rule/FakeSftpServerRuleTest.java +++ b/src/test/java/com/github/stefanbirkner/fakesftpserver/rule/FakeSftpServerRuleTest.java @@ -1,41 +1,66 @@ package com.github.stefanbirkner.fakesftpserver.rule; -import com.jcraft.jsch.*; -import org.apache.commons.io.IOUtils; -import org.assertj.core.api.ThrowableAssert.ThrowingCallable; -import org.junit.Test; -import org.junit.experimental.runners.Enclosed; -import org.junit.runner.RunWith; +import static com.github.stefanbirkner.fakesftpserver.rule.Executor.executeTestThatThrowsExceptionWithRule; +import static com.github.stefanbirkner.fakesftpserver.rule.Executor.executeTestWithRule; +import static com.github.stefanbirkner.fishbowl.Fishbowl.exceptionThrownBy; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.commons.io.IOUtils.toByteArray; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.net.ConnectException; +import java.net.URISyntaxException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Vector; import java.util.concurrent.atomic.AtomicInteger; -import static com.github.stefanbirkner.fakesftpserver.rule.Executor.executeTestThatThrowsExceptionWithRule; -import static com.github.stefanbirkner.fakesftpserver.rule.Executor.executeTestWithRule; -import static com.github.stefanbirkner.fishbowl.Fishbowl.exceptionThrownBy; -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.apache.commons.io.IOUtils.toByteArray; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.Assertions.catchThrowable; +import org.apache.commons.io.IOUtils; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.slf4j.LoggerFactory; + +import com.jcraft.jsch.ChannelSftp; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.JSchException; +import com.jcraft.jsch.Session; +import com.jcraft.jsch.SftpException; /* Wording according to the draft: * http://tools.ietf.org/html/draft-ietf-secsh-filexfer-13 */ @RunWith(Enclosed.class) public class FakeSftpServerRuleTest { + + private static final org.slf4j.Logger log = LoggerFactory.getLogger(FakeSftpServerRuleTest.class); + private static final byte[] DUMMY_CONTENT = new byte[]{1, 4, 2, 4, 2, 4}; private static final int DUMMY_PORT = 46354; private static final InputStream DUMMY_STREAM = new ByteArrayInputStream(DUMMY_CONTENT); private static final JSch JSCH = new JSch(); private static final int TIMEOUT = 500; + private static Path DUMMY_KEY; + private static Path DUMMY_AUTHORIZED_KEYS; + private static Path EMPTY_AUTHORIZED_KEYS; + private static final String DUMMY_KEY_PASSPHRASE = "unittest"; + + static { + try { + DUMMY_KEY = Paths.get(FakeSftpServerRuleTest.class.getResource("/keys/dummy_key").toURI()); + DUMMY_AUTHORIZED_KEYS = Paths.get(FakeSftpServerRuleTest.class.getResource("/keys/dummy_key.pub").toURI()); + EMPTY_AUTHORIZED_KEYS = Paths.get(FakeSftpServerRuleTest.class.getResource("/keys/empty_authorized_keys").toURI()); + } catch (URISyntaxException e) { + log.error("Error loading SSH keys", e); + } + } public static class round_trip { @Test @@ -105,6 +130,24 @@ public void the_server_accepts_connections_with_password() { sftpServer ); } + + @Test + public void the_server_accepts_connections_with_identity() { + FakeSftpServerRule sftpServer = new FakeSftpServerRule(); + executeTestWithRule( + () -> { + Session session = createSessionWithIdentity( + sftpServer, + "dummy user", + DUMMY_KEY.toString(), + DUMMY_KEY_PASSPHRASE + ); + session.connect(TIMEOUT); + JSCH.removeAllIdentity(); + }, + sftpServer + ); + } } public static class server_with_credentials_immediately_set { @@ -221,6 +264,99 @@ public void the_last_password_is_effective_if_addUser_is_called_multiple_times() } } + public static class server_with_identity_immediately_set { + + Path privateKeyPath; + Path authorizedKeysPath; + @Before + public void setupIdentity() throws URISyntaxException { + privateKeyPath = Paths.get(FakeSftpServerRuleTest.class.getResource("/keys/dummy_key").toURI()); + authorizedKeysPath = Paths.get(FakeSftpServerRuleTest.class.getResource("/keys/dummy_key.pub").toURI()); + } + + @Test + public void the_server_accepts_connections_with_correct_identity() { + FakeSftpServerRule sftpServer = new FakeSftpServerRule() + .addIdentity("dummy user", DUMMY_AUTHORIZED_KEYS); + executeTestWithRule( + () -> { + Session session = createSessionWithIdentity( + sftpServer, + "dummy user", + DUMMY_KEY.toString(), + DUMMY_KEY_PASSPHRASE + ); + session.connect(TIMEOUT); + JSCH.removeAllIdentity(); + }, + sftpServer + ); + } + + + @Test + public void the_server_rejects_connections_with_wrong_passphrase() { + FakeSftpServerRule sftpServer = new FakeSftpServerRule() + .addIdentity("dummy user", DUMMY_AUTHORIZED_KEYS); + executeTestWithRule( + () -> { + Session session = createSessionWithIdentity( + sftpServer, + "dummy user", + DUMMY_KEY.toString(), + "invalid" + ); + assertAuthenticationFails( + () -> session.connect(TIMEOUT) + ); + JSCH.removeAllIdentity(); + }, + sftpServer + ); + } + + @Test + public void the_server_rejects_connections_with_wrong_key() { + FakeSftpServerRule sftpServer = new FakeSftpServerRule() + .addIdentity("dummy user", EMPTY_AUTHORIZED_KEYS); + executeTestWithRule( + () -> { + Session session = createSessionWithIdentity( + sftpServer, + "dummy user", + DUMMY_KEY.toString(), + DUMMY_KEY_PASSPHRASE + ); + assertAuthenticationFails( + () -> session.connect(TIMEOUT) + ); + JSCH.removeAllIdentity(); + }, + sftpServer + ); + } + + @Test + public void the_last_key_is_effective_if_addIdentity_is_called_multiple_times() { + FakeSftpServerRule sftpServer = new FakeSftpServerRule() + .addIdentity("dummy user", EMPTY_AUTHORIZED_KEYS) + .addIdentity("dummy user", DUMMY_AUTHORIZED_KEYS); + executeTestWithRule( + () -> { + Session session = createSessionWithIdentity( + sftpServer, + "dummy user", + DUMMY_KEY.toString(), + DUMMY_KEY_PASSPHRASE + ); + session.connect(TIMEOUT); + JSCH.removeAllIdentity(); + }, + sftpServer + ); + } + } + private static Session createSessionWithCredentials( FakeSftpServerRule sftpServer, String username, @@ -230,13 +366,24 @@ private static Session createSessionWithCredentials( username, password, sftpServer.getPort() ); } + + private static Session createSessionWithIdentity( + FakeSftpServerRule sftpServer, + String username, + String prvkey, + String passphrase + ) throws JSchException { + return FakeSftpServerRuleTest.createSessionWithIdentity( + username, prvkey, passphrase, sftpServer.getPort() + ); + } private static void assertAuthenticationFails( ThrowingCallable connectToServer ) { assertThatThrownBy(connectToServer) .isInstanceOf(JSchException.class) - .hasMessage("Auth fail"); + .hasMessageMatching("(Auth|USERAUTH) fail"); } } @@ -1133,6 +1280,19 @@ private static Session createSessionWithCredentials( session.setPassword(password); return session; } + + private static Session createSessionWithIdentity( + String username, + String prvkey, + String passphrase, + int port + ) throws JSchException { + // if you need detailed information add a logger to JSCH + JSCH.addIdentity(prvkey, passphrase); + Session session = JSCH.getSession(username, "127.0.0.1", port); + session.setConfig("StrictHostKeyChecking", "no"); + return session; + } private static byte[] downloadFile( FakeSftpServerRule server, diff --git a/src/test/resources/keys/dummy_key b/src/test/resources/keys/dummy_key new file mode 100644 index 0000000..e18fe97 --- /dev/null +++ b/src/test/resources/keys/dummy_key @@ -0,0 +1,54 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-128-CBC,88AD208DA559409F4DB57D441645486E + +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 +-----END RSA PRIVATE KEY----- diff --git a/src/test/resources/keys/dummy_key.pub b/src/test/resources/keys/dummy_key.pub new file mode 100644 index 0000000..71bb26c --- /dev/null +++ b/src/test/resources/keys/dummy_key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDiHtfRvTbSJThOtPCq1FoVP6wfafvKh6GkfaOXSxZi6ysqG61sAqmd80rlItgG2U4CUiQTX4oDlif5BKaW4EFXIkDPFnD4HOAUT160phfUv2VVb9CUarce0cjWxRuLFiqYFe6XFX0FKdGlvRdRYF5JlAAUxjZ0VqQqMPVsIjU/eZmRTWA/pW20yzvxOWS+ac2O4q9esFB9uxtMOxg/JEmztoDcFmvjUqwVC462/ESKeA59IuBFI/OmDRd3OJkW3XwuA1UQyJlksaYM/I/SR9r6ZB72PFrmkNljB23jH2HAmO01ZxDuSQXekeMWqr7lOMe+vwjWtxiD7BR2gRDO6IGF+7Qc2ZDtTRXFRU7jYBonMBNPByQ+7oQy//3f55/6pU+HeZK4/S0X3Qd5n9en/F0n01kf/r7dkpIiDY+ndBjhWTuQ9jpqhyhHrIZq1V1WtfU3p+nuBBqKe1EatH9OJbXIjugldQLMqx4cT/e9lPiScp1zYRfa48c9JqRwKcce9UF3bz4NOjguTie1XB3FYDuRHiMaeOBs91iHbjjzFRkzCWyR+QVCDWFaZ8USmX7jopLRfU/2PCoKrr0IDiDbEHn60Rmah5SBYC3xZYEe2jD686sHI5o0G3lUzXxOpDVmq+b1SxSotQGpxz3fyu444k4mHOv+N0/+FT/06fzz5oU3fQ== pverdage@FR07154111L diff --git a/src/test/resources/keys/empty_authorized_keys b/src/test/resources/keys/empty_authorized_keys new file mode 100644 index 0000000..e69de29