~jan0sch/smederee
Showing details for patch 41c3bb902743192030102be19ba7e6e3223b143b.
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/ssh/SshServerProviderTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/ssh/SshServerProviderTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/ssh/SshServerProviderTest.scala 2025-02-01 21:56:19.655641519 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/ssh/SshServerProviderTest.scala 2025-02-01 21:56:19.655641519 +0000 @@ -61,7 +61,7 @@ port = portNumber, serverKeyFile = keyfile ) - val provider = new SshServerProvider(darcsConfig, sshConfig) + val provider = new SshServerProvider(darcsConfig, configuration.database, sshConfig) provider.run().use { server => assert(server.isStarted(), "Server not started!") assertEquals(server.getPort(), portNumber.toString.toInt) diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala 2025-02-01 21:56:19.655641519 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala 2025-02-01 21:56:19.655641519 +0000 @@ -148,7 +148,8 @@ // Create our ssh server fiber (or a dummy one if disabled). sshServerProvider = configuration.service.ssh.enabled match { case false => None - case true => Option(new SshServerProvider(configuration.service.darcs, configuration.service.ssh)) + case true => + Option(new SshServerProvider(configuration.service.darcs, configuration.database, configuration.service.ssh)) } sshServer = sshServerProvider.fold(IO.unit.as(ExitCode.Success))( _.run().use(server => diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/DarcsSshCommand.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/DarcsSshCommand.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/DarcsSshCommand.scala 2025-02-01 21:56:19.655641519 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/DarcsSshCommand.scala 2025-02-01 21:56:19.655641519 +0000 @@ -20,9 +20,13 @@ import java.io.{ File, InputStream, OutputStream } import java.nio.file.Paths +import cats._ +import cats.effect._ +import cats.effect.std.Dispatcher +import cats.effect.unsafe.implicits.global import cats.syntax.all._ import de.smederee.hub.config._ -import de.smederee.hub.{ Username, VcsRepositoryName } +import de.smederee.hub.{ UserId, Username, VcsRepositoryName } import org.apache.sshd.scp.common.ScpHelper import org.apache.sshd.scp.server._ import org.apache.sshd.server.channel.ChannelSession @@ -190,12 +194,39 @@ * @param darcsConfiguration * The configuration needed to properly execute underlying darcs commands and access repository data on the * filesystem. + * @param repository + * A repository providing the needed functionality like getting ssh keys and user/repo information. */ -final class DarcsSshCommandFactory(darcsConfiguration: DarcsConfiguration) extends CommandFactory { +final class DarcsSshCommandFactory(darcsConfiguration: DarcsConfiguration, repository: SshAuthenticationRepository[IO]) + extends CommandFactory { private val log = LoggerFactory.getLogger(classOf[DarcsSshCommandFactory]) + /** Check if the given repository of the given owner is readable by the user with the given id. + * + * @param ownerName + * The unique name (user name) of the owner of the repository. + * @param repoName + * The name of the repository which is unique within the context of the owner. + * @param userId + * The unique id of the account that is requesting access. + * @return + * Either `true` if the repository is readable by the user or `false` otherwise. + */ + protected def repositoryIsReadableBy(ownerName: Username, repoName: VcsRepositoryName, userId: UserId): Boolean = + Dispatcher[IO] + .use { dispatcher => + for { + _ <- IO.delay(log.debug(s"Checking if vcs repository $ownerName/$repoName is readable by $userId.")) + vcsOwner <- repository.findVcsRepositoryOwner(ownerName) + _ <- IO.delay(log.debug(s"VCS repository owner name maps to $vcsOwner.")) + userIsOwner = vcsOwner.exists(_.uid === userId) + } yield userIsOwner + } + .unsafeRunSync() + override def createCommand(channel: ChannelSession, command: String): Command = { log.debug(s"Requested SSH command: $command") + val sshKeyOwnerId = channel.getSession().getAttribute(SshServerConfiguration.SshKeyOwnerIdAttribute) command match { case DarcsSshCommandFactory.FilterDarcsApplyCommand( @@ -208,15 +239,21 @@ owner, repository ) => - (SshUsername.from(owner), VcsRepositoryName.from(repository)) - .mapN((owner, repository) => - new DarcsApply( - darcsConfiguration, - owner.toUsername, - repository, - debugFlag === "--debug" - ) - ) + ( + SshUsername.from(owner), + VcsRepositoryName.from(repository) + ) + .mapN { case (owner, repository) => + if (repositoryIsReadableBy(owner.toUsername, repository, sshKeyOwnerId)) + new DarcsApply( + darcsConfiguration, + owner.toUsername, + repository, + debugFlag === "--debug" + ) + else + new UnknownCommand("You are only allowed to access your own repositories!") + } .getOrElse(new UnknownCommand(command)) case DarcsSshCommandFactory.FilterDarcsTransferModeCommand( "transfer-mode", @@ -224,32 +261,58 @@ owner, repository ) => - (SshUsername.from(owner), VcsRepositoryName.from(repository)) - .mapN((owner, repository) => - // new DarcsTransferMode(darcsConfiguration, owner.toUsername, repository) - new UnknownCommand(command) // FIXME Make transfer-mode work (stalls currently). - ) + ( + SshUsername.from(owner), + VcsRepositoryName.from(repository) + ) + .mapN { case (owner, repository) => + if (repositoryIsReadableBy(owner.toUsername, repository, sshKeyOwnerId)) + // new DarcsTransferMode(darcsConfiguration, owner.toUsername, repository) + new UnknownCommand(command) // FIXME Make transfer-mode work (stalls currently). + else + new UnknownCommand("You are only allowed to access your own repositories!") + } .getOrElse(new UnknownCommand(command)) - case DarcsSshCommandFactory.FilterScpCommand("-f", _, "--", path) => - new ScpCommand( - channel, - command, - null, - ScpHelper.DEFAULT_SEND_BUFFER_SIZE, - ScpHelper.DEFAULT_RECEIVE_BUFFER_SIZE, - null, - null + case DarcsSshCommandFactory.FilterScpCommand("-f", ignored, "--", owner, repository, path) => + ( + SshUsername.from(owner), + VcsRepositoryName.from(repository) ) - case DarcsSshCommandFactory.FilterScpCommand("-f", null, null, path) => - new ScpCommand( - channel, - command, - null, - ScpHelper.DEFAULT_SEND_BUFFER_SIZE, - ScpHelper.DEFAULT_RECEIVE_BUFFER_SIZE, - null, - null + .mapN { case (owner, repository) => + if (repositoryIsReadableBy(owner.toUsername, repository, sshKeyOwnerId) && !path.contains("..")) + new ScpCommand( + channel, + command, + null, + ScpHelper.DEFAULT_SEND_BUFFER_SIZE, + ScpHelper.DEFAULT_RECEIVE_BUFFER_SIZE, + null, + null + ) + else + new UnknownCommand("You are only allowed to access your own repositories!") + } + .getOrElse(new UnknownCommand(command)) + case DarcsSshCommandFactory.FilterScpCommand("-f", null, null, owner, repository, path) => + ( + SshUsername.from(owner), + VcsRepositoryName.from(repository) ) + .mapN { case (owner, repository) => + if (repositoryIsReadableBy(owner.toUsername, repository, sshKeyOwnerId) && !path.contains("..")) + new ScpCommand( + channel, + command, + null, + ScpHelper.DEFAULT_SEND_BUFFER_SIZE, + ScpHelper.DEFAULT_RECEIVE_BUFFER_SIZE, + null, + null + ) + else + new UnknownCommand("You are only allowed to access your own repositories!") + } + .getOrElse(new UnknownCommand(command)) case _ => new UnknownCommand(command) } } @@ -262,6 +325,8 @@ val FilterDarcsTransferModeCommand: Regex = "^darcs (transfer-mode) (--repodir) ([a-z][a-z0-9]{1,31})/([a-zA-Z0-9][a-zA-Z0-9\\-_]{1,63})/?$".r // After the initial transfer-mode call darcs will fallback to using scp. - val FilterScpCommand: Regex = "^scp (-f) ((--)\\s)?([^\u0000]+)$".r + // val FilterScpCommand: Regex = "^scp (-f) ((--)\\s)?([^\u0000]+)$".r + val FilterScpCommand: Regex = + "^scp (-f) ((--)\\s)?([a-z][a-z0-9]{1,31})/([a-zA-Z0-9][a-zA-Z0-9\\-_]{1,63})/([^\u0000]+)$".r // TODO Add support for sftp because that could be enforced from the client side. } diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala 2025-02-01 21:56:19.655641519 +0000 @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.ssh + +import java.util.UUID + +import cats.effect._ +import de.smederee.hub._ +import doobie._ +import doobie.Fragments._ +import doobie.implicits._ +import doobie.postgres.implicits._ +import org.slf4j.LoggerFactory + +final class DoobieSshAuthenticationRepository[F[_]: Sync](tx: Transactor[F]) extends SshAuthenticationRepository[F] { + private final val log = LoggerFactory.getLogger(getClass) + + given Meta[EncodedKeyBytes] = Meta[String].timap(EncodedKeyBytes.unsafeFrom)(_.toString) + given Meta[KeyComment] = Meta[String].timap(KeyComment.apply)(_.toString) + given Meta[KeyFingerprint] = Meta[String].timap(KeyFingerprint.apply)(_.toString) + given Meta[SshKeyType] = Meta[String].timap(id => SshKeyType.Mappings(id))(_.identifier) + given Meta[UserId] = Meta[UUID].timap(UserId.apply)(_.toUUID) + given Meta[Username] = Meta[String].timap(Username.apply)(_.toString) + given Meta[VcsRepositoryName] = Meta[String].timap(VcsRepositoryName.apply)(_.toString) + + override def findSshKey(fingerprint: KeyFingerprint): F[Option[PublicSshKey]] = + sql"""SELECT id, uid, key_type, key, fingerprint, comment, created_at, last_used_at FROM "ssh_keys" WHERE fingerprint = $fingerprint""" + .query[PublicSshKey] + .option + .transact(tx) + + override def findVcsRepositoryOwner(name: Username): F[Option[VcsRepositoryOwner]] = + sql"""SELECT uid, name FROM "accounts" WHERE name = $name LIMIT 1""" + .query[VcsRepositoryOwner] + .option + .transact(tx) + + override def updateLastUsed(keyId: UUID): F[Int] = + sql"""UPDATE "ssh_keys" SET last_used_at = NOW() WHERE id = $keyId""".update.run.transact(tx) + +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala 2025-02-01 21:56:19.655641519 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala 2025-02-01 21:56:19.655641519 +0000 @@ -24,6 +24,7 @@ import cats._ import cats.syntax.all._ import de.smederee.hub._ +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry import org.bouncycastle.crypto.util.OpenSSHPublicKeyUtil import scala.util.Try @@ -257,7 +258,7 @@ val rawFingerprint = Try { val publicKey = OpenSSHPublicKeyUtil.parsePublicKey(base64Key.toByteArray) val digestedKey = digest.digest(OpenSSHPublicKeyUtil.encodePublicKey(publicKey)) - Base64.getEncoder().encodeToString(digestedKey).reverse.dropWhile(_ == '=').reverse // Remove padding (`=`) + Base64.getEncoder().withoutPadding().encodeToString(digestedKey) }.toOption rawFingerprint.flatMap(KeyFingerprint.from) } @@ -266,3 +267,28 @@ } } } + +extension (sshKey: PublicSshKey) { + + /** Convert this key into an instance of a [[SshPublicKeyStringhPublicKeyString]]. + * + * @return + * A well formatted ssh public key string. + */ + def toSshPublicKeyString: SshPublicKeyString = + SshPublicKeyString( + s"""${sshKey.keyType.identifier} ${sshKey.keyBytes.toString}${sshKey.comment + .map(c => s" ${c.toString}") + .getOrElse("")}""" + ) + + /** Convert this key into an instance of a AuthorizedKeyEntry useable by the Apache Mina SSHD library. + * + * @return + * Either the converted AuthorizedKeyEntry or an error message. + */ + def toAuthorizedKeyEntry: Either[String, AuthorizedKeyEntry] = + Try { + AuthorizedKeyEntry.parseAuthorizedKeyEntry(sshKey.toSshPublicKeyString.toString) + }.toEither.leftMap(_.getMessage()) +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticationRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticationRepository.scala 2025-02-01 21:56:19.655641519 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticationRepository.scala 2025-02-01 21:56:19.655641519 +0000 @@ -45,16 +45,14 @@ */ def findSshKey(fingerprint: KeyFingerprint): F[Option[PublicSshKey]] - /** Search for the vcs repository entry with the given owner and name. + /** Search for a repository owner of whom we only know the name. * - * @param ownerName - * The name of the repository owner. - * @param repoName - * The repository name which must be unique in regard to the owner. + * @param name + * The name of the repository owner which is the username of the actual owners account. * @return - * An option to the successfully found vcs repository entry. + * An option to successfully found repository owner. */ - def findVcsRepository(ownerName: Username, repoName: VcsRepositoryName): F[Option[VcsRepository]] + def findVcsRepositoryOwner(name: Username): F[Option[VcsRepositoryOwner]] /** Update the last used column for the key in the database. * diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticator.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticator.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticator.scala 2025-02-01 21:56:19.655641519 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticator.scala 2025-02-01 21:56:19.655641519 +0000 @@ -19,11 +19,21 @@ import java.security.PublicKey +import cats._ +import cats.effect._ +import cats.effect.std.Dispatcher +import cats.effect.unsafe.implicits.global +import cats.syntax.all._ +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry +import org.apache.sshd.common.config.keys.KeyUtils +import org.apache.sshd.common.config.keys.PublicKeyEntryResolver import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator import org.apache.sshd.server.session.ServerSession -import org.apache.sshd.common.AttributeRepository import org.slf4j.LoggerFactory +import scala.jdk.CollectionConverters._ +import scala.util.Try + /** A custom PublickeyAuthenticator implementation for restricting access via ssh. * * Currently we follow this flow: @@ -38,10 +48,80 @@ * * @param genericUser * A name which represents a ssh user name that can be used for generic access. + * @param repository + * A repository providing the needed functionality like getting ssh keys and user/repo information. */ -final class SshAuthenticator(genericUser: SshUsername) extends PublickeyAuthenticator { +final class SshAuthenticator( + genericUser: SshUsername, + repository: SshAuthenticationRepository[IO] +) extends PublickeyAuthenticator { private val log = LoggerFactory.getLogger(getClass) - override def authenticate(username: String, key: PublicKey, session: ServerSession): Boolean = true // FIXME + override def authenticate(providedUsername: String, providedPublicKey: PublicKey, session: ServerSession): Boolean = + Dispatcher[IO] + .use { dispatcher => + val check = for { + _ <- IO.delay(log.debug(s"Authentication request for $providedUsername from ${session.getRemoteAddress()}.")) + convertedName <- IO.delay(SshUsername.from(providedUsername)) + usernameIsValid <- IO.delay(convertedName.exists(_ === genericUser)) + keyIsValid <- + if (usernameIsValid) + for { + fingerprint <- IO.delay( + KeyFingerprint.from(KeyUtils.getFingerPrint(providedPublicKey).drop(7)) + ) // drop the "SHA256:" + _ <- IO.delay( + fingerprint.map(fingerprint => + log.debug(s"User provided ${providedPublicKey.getAlgorithm()} key with fingerprint: $fingerprint.") + ) + ) + providedKeyBytes <- IO.delay(providedPublicKey.getEncoded()) + possibleUserKey <- fingerprint match { + case None => IO.pure(None) + case Some(fingerprint) => repository.findSshKey(fingerprint) + } + _ <- possibleUserKey match { + case None => + IO.delay(log.warn(s"No ssh key found in the database for given fingerprint: $fingerprint!")) + case Some(userKey) => + IO.delay(log.debug(s"Found matching ${userKey.keyType} key (owner: ${userKey.ownerId}).")) + } + keyIsValid <- possibleUserKey match { + case Some(userKey) => + // We create a `AuthorizedKeyEntry` from our stored key and delegate the key validation to mina sshd. + // Also we store the owner id of the key in the server session for later usage. + IO.delay { + userKey.toAuthorizedKeyEntry match { + case Left(error) => + log.error(s"Could not convert user key ${userKey.fingerprint} to AuthorizedKeyEntry: $error") + false + case Right(authKey) => + session.setAttribute(SshServerConfiguration.SshKeyOwnerIdAttribute, userKey.ownerId) + val authenticator = PublickeyAuthenticator.fromAuthorizedEntries( + userKey.fingerprint.toString, + session, + List(authKey).asJava, + PublicKeyEntryResolver.IGNORING + ) + authenticator.authenticate(providedUsername, providedPublicKey, session) + } + } + case _ => IO.pure(false) + } + _ <- IO.delay { + if (keyIsValid) + log.debug("Keys are matching!") + else + log.info("Stored key and provided one are not matching!") + } + } yield keyIsValid + else + for { + _ <- IO.delay(log.debug(s"Provided username $providedUsername was not accepted!")) + } yield false + } yield usernameIsValid && keyIsValid + check + } + .unsafeRunSync() } diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala 2025-02-01 21:56:19.655641519 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala 2025-02-01 21:56:19.655641519 +0000 @@ -20,19 +20,22 @@ import java.nio.file.* import java.util.Collections -import cats.* -import cats.data.* -import cats.effect.* -import cats.syntax.all.* -import com.comcast.ip4s.* -import de.smederee.hub.Username +import cats._ +import cats.data._ +import cats.effect._ +import cats.effect.std.Dispatcher +import cats.syntax.all._ +import com.comcast.ip4s._ +import de.smederee.hub.{ UserId, Username } import de.smederee.hub.config._ +import doobie._ +import org.apache.sshd.common.AttributeRepository.AttributeKey import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory import org.apache.sshd.scp.server.ScpCommandFactory import org.apache.sshd.server.SshServer import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider import org.apache.sshd.sftp.server.SftpSubsystemFactory -import pureconfig.* +import pureconfig._ import scala.util.matching.Regex @@ -98,6 +101,9 @@ // The default configuration key under which to lookup the ssh server configuration. final val parentKey: ConfigKey = ConfigKey("ssh") + // A key marker for storing the owner id of an ssh-key in the ssh server session. + final val SshKeyOwnerIdAttribute: AttributeKey[UserId] = new AttributeKey[UserId]() + given Eq[SshServerConfiguration] = Eq.fromUniversalEquals given ConfigReader[Host] = ConfigReader.fromStringOpt[Host](Host.fromString) @@ -116,11 +122,14 @@ * @param darcsConfiguration * The configuration needed to properly execute underlying darcs commands and access repository data on the * filesystem. + * @param databaseConfiguration + * The configuration needed to access the database. * @param sshConfiguration * The configuration with all values needed to properly configure the underlying Apache sshd server. */ final class SshServerProvider( darcsConfiguration: DarcsConfiguration, + databaseConfiguration: DatabaseConfig, sshConfiguration: SshServerConfiguration ) { @@ -130,14 +139,21 @@ * An instance of a Apache MINA SSHD server which is not yet started. */ private def createServer(): SshServer = { - val server = SshServer.setUpDefaultServer() + val transactor = Transactor.fromDriverManager[IO]( + databaseConfiguration.driver, + databaseConfiguration.url, + databaseConfiguration.user, + databaseConfiguration.pass + ) + val repository = new DoobieSshAuthenticationRepository[IO](transactor) + val server = SshServer.setUpDefaultServer() server.setHost(sshConfiguration.host.toString) server.setPort(sshConfiguration.port.toString.toInt) val keyProvider = new SimpleGeneratorHostKeyProvider(sshConfiguration.serverKeyFile) keyProvider.setAlgorithm("EC") keyProvider.setOverwriteAllowed(false) server.setKeyPairProvider(keyProvider) - server.setPublickeyAuthenticator(new SshAuthenticator(sshConfiguration.genericUser)) + server.setPublickeyAuthenticator(new SshAuthenticator(sshConfiguration.genericUser, repository)) server.setShellFactory( new NoLogin(sshConfiguration.genericUser, sshConfiguration.host, sshConfiguration.port) ) @@ -147,7 +163,7 @@ // Add our custom command factory which must provide darcs and scp functionality. // val sftpSubsystem = new SftpSubsystemFactory.Builder().build() // server.setSubsystemFactories(Collections.singletonList(sftpSubsystem)) - val darcsCommand = new DarcsSshCommandFactory(darcsConfiguration) + val darcsCommand = new DarcsSshCommandFactory(darcsConfiguration, repository) server.setCommandFactory(darcsCommand) server }