From 6a9272bd42fb92bb2ea02a72cb0522af2b622adf Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 12 Jul 2024 11:20:22 +0200 Subject: [PATCH] Make it easier to configure and to disable JWS for delegation (#5065) * Make it easier to configure and to disable JWS for delegation * Fix test compilation --------- Co-authored-by: Simon Dumas --- delta/app/src/main/resources/app.conf | 9 +++ .../nexus/delta/config/AppConfig.scala | 4 +- .../nexus/delta/wiring/DeltaModule.scala | 6 ++ .../plugins/storage/StoragePluginModule.scala | 18 +---- .../storage/files/model/FileRejection.scala | 7 +- .../files/routes/DelegateFilesRoutes.scala | 7 +- .../storage/files/routes/TokenIssuer.scala | 80 ------------------- .../storage/storages/StoragesConfig.scala | 19 +---- .../storage/storages/StorageFixtures.scala | 2 +- .../s3/LocalStackS3StorageClient.scala | 3 +- .../nexus/delta/sdk/jws/JWSConfig.scala | 25 ++++++ .../nexus/delta/sdk/jws/JWSError.scala | 49 ++++++++++++ .../delta/sdk/jws/JWSPayloadHelper.scala | 74 +++++++++++++++++ .../nexus/delta/sdk/jws/RSAUtils.scala | 32 ++++++++ .../sdk/marshalling/RdfExceptionHandler.scala | 2 + .../sdk/jws/JWSPayloadHelperSuite.scala} | 37 +++++---- .../ship/config/ShipConfigFixtures.scala | 3 +- tests/docker/config/delta-postgres.conf | 10 ++- 18 files changed, 237 insertions(+), 150 deletions(-) delete mode 100644 delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/TokenIssuer.scala create mode 100644 delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSConfig.scala create mode 100644 delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSError.scala create mode 100644 delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSPayloadHelper.scala create mode 100644 delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/RSAUtils.scala rename delta/{plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/TokenIssuerSuite.scala => sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSPayloadHelperSuite.scala} (70%) diff --git a/delta/app/src/main/resources/app.conf b/delta/app/src/main/resources/app.conf index 52e0e12892..381fbfd66a 100644 --- a/delta/app/src/main/resources/app.conf +++ b/delta/app/src/main/resources/app.conf @@ -83,6 +83,15 @@ app { error-handling: "strict" } + jws { + # JWS configuration to sign and verify JWS payloads. + # Those are used for delegation operations + type = "disabled" + #type: enabled + #private-key = TO_OVERWRITE + ttl = 3 hours + } + # Identities configuration identities { # The max number of tokens in the groups cache diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/config/AppConfig.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/config/AppConfig.scala index ce0235a84e..2f24306530 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/config/AppConfig.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/config/AppConfig.scala @@ -7,6 +7,7 @@ import ch.epfl.bluebrain.nexus.delta.kernel.config.Configs import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.api.JsonLdApiConfig import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclsConfig import ch.epfl.bluebrain.nexus.delta.sdk.fusion.FusionConfig +import ch.epfl.bluebrain.nexus.delta.sdk.jws.JWSConfig import ch.epfl.bluebrain.nexus.delta.sdk.model.ServiceAccountConfig import ch.epfl.bluebrain.nexus.delta.sdk.organizations.OrganizationsConfig import ch.epfl.bluebrain.nexus.delta.sdk.permissions.PermissionsConfig @@ -52,7 +53,8 @@ final case class AppConfig( sse: SseConfig, projections: ProjectionConfig, fusion: FusionConfig, - `export`: ExportConfig + `export`: ExportConfig, + jws: JWSConfig ) object AppConfig { diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/DeltaModule.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/DeltaModule.scala index 1307513634..53cb01420b 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/DeltaModule.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/DeltaModule.scala @@ -23,6 +23,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.acls.Acls import ch.epfl.bluebrain.nexus.delta.sdk.fusion.FusionConfig import ch.epfl.bluebrain.nexus.delta.sdk.http.StrictEntity import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.ServiceAccount +import ch.epfl.bluebrain.nexus.delta.sdk.jws.JWSPayloadHelper import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.{RdfExceptionHandler, RdfRejectionHandler} import ch.epfl.bluebrain.nexus.delta.sdk.model.ComponentDescription.PluginDescription import ch.epfl.bluebrain.nexus.delta.sdk.model._ @@ -126,6 +127,11 @@ class DeltaModule(appCfg: AppConfig, config: Config)(implicit classLoader: Class List("@context", "@id", "@type", "reason", "details", "sourceId", "projectionId", "_total", "_results") ) ) + + make[JWSPayloadHelper].from { config: AppConfig => + JWSPayloadHelper(config.jws) + } + make[ActorSystem[Nothing]].fromResource { () => val make = IO.delay( ActorSystem[Nothing]( diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/StoragePluginModule.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/StoragePluginModule.scala index b70a7440d5..8759b26549 100644 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/StoragePluginModule.scala +++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/StoragePluginModule.scala @@ -10,10 +10,9 @@ import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.Files.FilesLog import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.batch.{BatchCopy, BatchFiles} import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.contexts.{files => fileCtxId} import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model._ -import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.routes.{BatchFilesRoutes, DelegateFilesRoutes, FilesRoutes, TokenIssuer} +import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.routes.{BatchFilesRoutes, DelegateFilesRoutes, FilesRoutes} import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.schemas.{files => filesSchemaId} import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.{FileAttributesUpdateStream, Files} -import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.StoragesConfig.S3StorageConfig.DelegationConfig import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.StoragesConfig.{ShowFileLocation, StorageTypeConfig} import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.contexts.{storages => storageCtxId, storagesMetadata => storageMetaCtxId} import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model._ @@ -39,6 +38,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.fusion.FusionConfig import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClient import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.ServiceAccount +import ch.epfl.bluebrain.nexus.delta.sdk.jws.JWSPayloadHelper import ch.epfl.bluebrain.nexus.delta.sdk.model._ import ch.epfl.bluebrain.nexus.delta.sdk.model.metrics.ScopedEventMetricEncoder import ch.epfl.bluebrain.nexus.delta.sdk.permissions.{Permissions, StoragePermissionProvider} @@ -49,13 +49,9 @@ import ch.epfl.bluebrain.nexus.delta.sdk.sse.SseEncoder import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label import ch.epfl.bluebrain.nexus.delta.sourcing.stream.Supervisor import ch.epfl.bluebrain.nexus.delta.sourcing.{ScopedEventLog, Transactors} -import com.nimbusds.jose.jwk.gen.RSAKeyGenerator import com.typesafe.config.Config import izumi.distage.model.definition.{Id, ModuleDef} -import java.security.interfaces.RSAPrivateCrtKey -import scala.concurrent.duration.DurationInt - /** * Storages and Files wiring */ @@ -278,8 +274,8 @@ class StoragePluginModule(priority: Int) extends ModuleDef { make[DelegateFilesRoutes].from { ( - cfg: StorageTypeConfig, identities: Identities, + jwsPayloadHelper: JWSPayloadHelper, aclCheck: AclCheck, files: Files, schemeDirectives: DeltaSchemeDirectives, @@ -290,17 +286,11 @@ class StoragePluginModule(priority: Int) extends ModuleDef { ordering: JsonKeyOrdering, showLocation: ShowFileLocation ) => - val delegationCfg = cfg.amazon - .flatMap(_.delegation) - .getOrElse( - DelegationConfig(new RSAKeyGenerator(2048).generate().toRSAPrivateKey.asInstanceOf[RSAPrivateCrtKey], 3.days) - ) - val tokenIssuer = new TokenIssuer(delegationCfg.rsaKey, delegationCfg.tokenDuration) new DelegateFilesRoutes( identities, aclCheck, files, - tokenIssuer, + jwsPayloadHelper, indexingAction(_, _, _)(shift), schemeDirectives )(baseUri, cr, ordering, showLocation) diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileRejection.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileRejection.scala index 21cdeb1514..6a43341df0 100644 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileRejection.scala +++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileRejection.scala @@ -18,7 +18,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.syntax.httpResponseFieldsSyntax import ch.epfl.bluebrain.nexus.delta.sourcing.model.ProjectRef import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag import io.circe.syntax._ -import io.circe.{Encoder, Json, JsonObject} +import io.circe.{Encoder, JsonObject} /** * Enumeration of File rejection types. @@ -231,10 +231,6 @@ object FileRejection { s"Linking or registering a file cannot be performed without a 'filename' or a 'path' that does not end with a filename." ) - final case object InvalidJWSPayload extends FileRejection("Signature missing, flattened JWS format expected") - - final case class JWSSignatureExpired(payload: Json) extends FileRejection(s"Token expired for payload: $payload") - final case class CopyRejection( sourceProj: ProjectRef, destProject: ProjectRef, @@ -285,7 +281,6 @@ object FileRejection { case FetchRejection(_, _, FetchFileRejection.FileNotFound(_)) => (StatusCodes.InternalServerError, Seq.empty) case SaveRejection(_, _, SaveFileRejection.ResourceAlreadyExists(_)) => (StatusCodes.Conflict, Seq.empty) case SaveRejection(_, _, SaveFileRejection.BucketAccessDenied(_, _, _)) => (StatusCodes.Forbidden, Seq.empty) - case JWSSignatureExpired(_) => (StatusCodes.Forbidden, Seq.empty) case CopyRejection(_, _, _, rejection) => (rejection.status, Seq.empty) case FetchRejection(_, _, _) => (StatusCodes.InternalServerError, Seq.empty) case SaveRejection(_, _, _) => (StatusCodes.InternalServerError, Seq.empty) diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/DelegateFilesRoutes.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/DelegateFilesRoutes.scala index 09f4cb1a9a..5e39797e55 100644 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/DelegateFilesRoutes.scala +++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/DelegateFilesRoutes.scala @@ -27,13 +27,14 @@ import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} import io.circe.syntax.EncoderOps import io.circe.{Decoder, Encoder, Json} import ch.epfl.bluebrain.nexus.delta.sdk.implicits._ +import ch.epfl.bluebrain.nexus.delta.sdk.jws.JWSPayloadHelper import ch.epfl.bluebrain.nexus.delta.sourcing.model.ProjectRef final class DelegateFilesRoutes( identities: Identities, aclCheck: AclCheck, files: Files, - tokenIssuer: TokenIssuer, + jwsPayloadHelper: JWSPayloadHelper, index: IndexingAction.Execute[File], schemeDirectives: DeltaSchemeDirectives )(implicit @@ -84,7 +85,7 @@ final class DelegateFilesRoutes( ) = for { delegationResp <- files.delegate(project, desc, storageId) - jwsPayload <- tokenIssuer.issueJWSPayload(delegationResp.asJson) + jwsPayload <- jwsPayloadHelper.sign(delegationResp.asJson) } yield jwsPayload private def registerDelegatedFile( @@ -94,7 +95,7 @@ final class DelegateFilesRoutes( mode: IndexingMode )(implicit c: Caller): IO[FileResource] = for { - originalPayload <- tokenIssuer.verifyJWSPayload(jwsPayload) + originalPayload <- jwsPayloadHelper.verify(jwsPayload) delegationResponse <- IO.fromEither(originalPayload.as[DelegationResponse]) fileId = FileId(delegationResponse.id, project) fileResource <- diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/TokenIssuer.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/TokenIssuer.scala deleted file mode 100644 index cae6d11b16..0000000000 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/TokenIssuer.scala +++ /dev/null @@ -1,80 +0,0 @@ -package ch.epfl.bluebrain.nexus.delta.plugins.storage.files.routes - -import cats.effect.{Clock, IO} -import ch.epfl.bluebrain.nexus.delta.kernel.Logger -import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileRejection.{InvalidJWSPayload, JWSSignatureExpired} -import com.nimbusds.jose.crypto.{RSASSASigner, RSASSAVerifier} -import com.nimbusds.jose.jwk.RSAKey -import com.nimbusds.jose.util.JSONObjectUtils -import com.nimbusds.jose.{JWSAlgorithm, JWSHeader, JWSObjectJSON, Payload} -import io.circe.{parser, Json, Printer} - -import java.security.KeyFactory -import java.security.interfaces.{RSAPrivateCrtKey, RSAPublicKey} -import java.security.spec.{PKCS8EncodedKeySpec, RSAPublicKeySpec} -import java.util.Base64 -import scala.concurrent.duration.FiniteDuration -import scala.jdk.CollectionConverters.ListHasAsScala -import scala.util.Try - -class TokenIssuer(key: RSAKey, tokenValidity: FiniteDuration)(implicit clock: Clock[IO]) { - private val signer = new RSASSASigner(key) - private val verifier = new RSASSAVerifier(key.toPublicJWK) - private val TokenValiditySeconds = tokenValidity.toSeconds - private val log = Logger[TokenIssuer] - - def issueJWSPayload(payloadToSign: Json): IO[Json] = - for { - now <- clock.realTimeInstant - jwsObject = mkJWSObject(payloadToSign) - _ <- IO.delay(jwsObject.sign(mkJWSHeader(now.getEpochSecond + TokenValiditySeconds), signer)) - serialized <- IO.delay(jwsObject.serializeFlattened()) - json <- IO.fromEither(parser.parse(serialized)) - } yield json - - def verifyJWSPayload(payload: Json): IO[Json] = - for { - jwsObject <- IO.delay(JWSObjectJSON.parse(payload.toString())) - sig <- IO.fromOption(jwsObject.getSignatures.asScala.headOption)(InvalidJWSPayload) - _ <- IO.delay(sig.verify(verifier)) - objectPayload = jwsObject.getPayload.toString - originalPayload <- IO.fromEither(parser.parse(objectPayload)) - _ <- log.info(s"Original payload parsed for token: $originalPayload") - now <- clock.realTimeInstant - exp <- IO.delay(sig.getHeader.getCustomParam("exp").asInstanceOf[Long]) - _ <- IO.raiseWhen(now.getEpochSecond > exp)(JWSSignatureExpired(originalPayload)) - } yield originalPayload - - private def mkJWSHeader(expSeconds: Long): JWSHeader = - new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.getKeyID).customParam("exp", expSeconds).build() - - private def mkJWSObject(payload: Json) = new JWSObjectJSON(mkPayload(payload)) - - private def mkPayload(raw: Json) = { - val jsonObjectMap = JSONObjectUtils.parse(raw.printWith(Printer.noSpacesSortKeys)) - new Payload(jsonObjectMap) - } -} - -object TokenIssuer { - - def generateRSAKeyFromPrivate(privateKey: RSAPrivateCrtKey): RSAKey = { - val publicKeySpec: RSAPublicKeySpec = new RSAPublicKeySpec(privateKey.getModulus, privateKey.getPublicExponent) - val kf = KeyFactory.getInstance("RSA") - val publicKey = kf.generatePublic(publicKeySpec).asInstanceOf[RSAPublicKey] - new RSAKey.Builder(publicKey).privateKey(privateKey).build() - } - - def parseRSAPrivateKey(raw: String): Try[RSAPrivateCrtKey] = Try { - val keyStripped = raw - .replace("-----END PRIVATE KEY-----", "") - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("\n", "") - val keyStrippedDecoded = Base64.getDecoder.decode(keyStripped) - - val keySpec = new PKCS8EncodedKeySpec(keyStrippedDecoded) - val kf = KeyFactory.getInstance("RSA") - kf.generatePrivate(keySpec).asInstanceOf[RSAPrivateCrtKey] - } - -} diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StoragesConfig.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StoragesConfig.scala index a1df2a88d4..41b47a7eab 100644 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StoragesConfig.scala +++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StoragesConfig.scala @@ -3,8 +3,6 @@ package ch.epfl.bluebrain.nexus.delta.plugins.storage.storages import akka.http.scaladsl.model.Uri import cats.implicits._ import ch.epfl.bluebrain.nexus.delta.kernel.Secret -import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.routes.TokenIssuer -import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.StoragesConfig.S3StorageConfig.DelegationConfig import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.StoragesConfig.StorageTypeConfig import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.{AbsolutePath, DigestAlgorithm, StorageType} import ch.epfl.bluebrain.nexus.delta.sdk.auth.Credentials @@ -12,14 +10,11 @@ import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri import ch.epfl.bluebrain.nexus.delta.sdk.model.search.PaginationConfig import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission import ch.epfl.bluebrain.nexus.delta.sourcing.config.EventLogConfig -import com.nimbusds.jose.jwk.RSAKey import pureconfig.ConvertHelpers.{catchReadError, optF} import pureconfig.error.{CannotConvert, ConfigReaderFailures, ConvertFailure, FailureReason} import pureconfig.generic.auto._ -import pureconfig.generic.semiauto.deriveReader import pureconfig.{ConfigConvert, ConfigReader} -import java.security.interfaces.RSAPrivateCrtKey import scala.concurrent.duration.FiniteDuration /** @@ -186,23 +181,11 @@ object StoragesConfig { showLocation: Boolean, defaultMaxFileSize: Long, defaultBucket: String, - prefix: Option[Uri], - delegation: Option[DelegationConfig] + prefix: Option[Uri] ) extends StorageTypeEntryConfig { val prefixUri: Uri = prefix.getOrElse(Uri.Empty) } - object S3StorageConfig { - final case class DelegationConfig(privateKey: RSAPrivateCrtKey, tokenDuration: FiniteDuration) { - val rsaKey: RSAKey = TokenIssuer.generateRSAKeyFromPrivate(privateKey) - } - - implicit val delegationReader: ConfigReader[DelegationConfig] = deriveReader - - implicit private val privateKeyConvert: ConfigConvert[RSAPrivateCrtKey] = - ConfigConvert.viaStringTry[RSAPrivateCrtKey](TokenIssuer.parseRSAPrivateKey, _.toString) - } - /** * Remote Disk storage configuration * diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StorageFixtures.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StorageFixtures.scala index 964dd5a8a3..8798863b42 100644 --- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StorageFixtures.scala +++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StorageFixtures.scala @@ -32,7 +32,7 @@ trait StorageFixtures extends CirceLiteral { implicit val config: StorageTypeConfig = StorageTypeConfig( disk = DiskStorageConfig(diskVolume, Set(diskVolume,tmpVolume), DigestAlgorithm.default, permissions.read, permissions.write, showLocation = false, 50), amazon = Some(S3StorageConfig("localhost", useDefaultCredentialProvider = false, Secret("my_key"), Secret("my_secret_key"), - permissions.read, permissions.write, showLocation = false, 60, defaultBucket = "potato", prefix = None, delegation = None)), + permissions.read, permissions.write, showLocation = false, 60, defaultBucket = "potato", prefix = None)), remoteDisk = Some(RemoteDiskStorageConfig(DigestAlgorithm.default, BaseUri("http://localhost", Label.unsafe("v1")), Anonymous, permissions.read, permissions.write, showLocation = false, 70, 50.millis)), ) implicit val showLocation: StoragesConfig.ShowFileLocation = config.showFileLocation diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/s3/LocalStackS3StorageClient.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/s3/LocalStackS3StorageClient.scala index 4e7d491216..7795ce9b65 100644 --- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/s3/LocalStackS3StorageClient.scala +++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/s3/LocalStackS3StorageClient.scala @@ -53,8 +53,7 @@ object LocalStackS3StorageClient { showLocation = false, defaultMaxFileSize = 1, defaultBucket = defaultBucket, - prefix = Some(prefix), - delegation = None + prefix = Some(prefix) ) (S3StorageClient.unsafe(client), client, conf) } diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSConfig.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSConfig.scala new file mode 100644 index 0000000000..3ceef0f32a --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSConfig.scala @@ -0,0 +1,25 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.jws + +import ch.epfl.bluebrain.nexus.delta.plugins.storage.jws.RSAUtils +import com.nimbusds.jose.jwk.RSAKey +import pureconfig.generic.auto._ +import pureconfig.generic.semiauto.deriveReader +import pureconfig.{ConfigConvert, ConfigReader} + +import java.security.interfaces.RSAPrivateCrtKey +import scala.concurrent.duration.FiniteDuration + +sealed trait JWSConfig + +object JWSConfig { + final case object Disabled extends JWSConfig + + final case class Enabled(privateKey: RSAPrivateCrtKey, ttl: FiniteDuration) extends JWSConfig { + val rsaKey: RSAKey = RSAUtils.generateRSAKeyFromPrivate(privateKey) + } + + implicit private val privateKeyConvert: ConfigConvert[RSAPrivateCrtKey] = + ConfigConvert.viaStringTry[RSAPrivateCrtKey](RSAUtils.parseRSAPrivateKey, _.toString) + + implicit val delegationReader: ConfigReader[JWSConfig] = deriveReader +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSError.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSError.scala new file mode 100644 index 0000000000..807cc3460a --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSError.scala @@ -0,0 +1,49 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.jws + +import akka.http.scaladsl.model.StatusCodes +import ch.epfl.bluebrain.nexus.delta.kernel.utils.ClassUtils +import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.BNode +import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.ContextValue +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.JsonLdContext.keywords +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder +import ch.epfl.bluebrain.nexus.delta.sdk.error.SDKError +import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.HttpResponseFields +import io.circe.syntax.KeyOps +import io.circe.{Encoder, JsonObject} + +/** + * Rejections related to JWS operations + * + * @param reason + * a descriptive message for reasons why the JWS operations failed + */ +sealed abstract class JWSError(val reason: String) extends SDKError { + override def getMessage: String = reason +} + +object JWSError { + + final case object UnconfiguredJWS + extends JWSError("JWS config is incorrect or missing. Please contact your administrator.") + + final case object InvalidJWSPayload extends JWSError("Signature missing, flattened JWS format expected") + + final case object JWSSignatureExpired extends JWSError("The payload expired") + + implicit val jwsErrorHttpResponseFields: HttpResponseFields[JWSError] = HttpResponseFields.fromStatusAndHeaders { + case InvalidJWSPayload => (StatusCodes.BadRequest, Seq.empty) + case JWSSignatureExpired => (StatusCodes.Forbidden, Seq.empty) + case UnconfiguredJWS => (StatusCodes.InternalServerError, Seq.empty) + } + + implicit val jwsErrorEncoder: Encoder.AsObject[JWSError] = + Encoder.AsObject.instance { e => + val tpe = ClassUtils.simpleName(e) + JsonObject(keywords.tpe := tpe, "reason" := e.reason) + } + + implicit final val jwsErrorJsonLdEncoder: JsonLdEncoder[JWSError] = + JsonLdEncoder.computeFromCirce(id = BNode.random, ctx = ContextValue(contexts.error)) + +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSPayloadHelper.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSPayloadHelper.scala new file mode 100644 index 0000000000..31184e1c5f --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSPayloadHelper.scala @@ -0,0 +1,74 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.jws + +import cats.effect.{Clock, IO} +import ch.epfl.bluebrain.nexus.delta.kernel.Logger +import ch.epfl.bluebrain.nexus.delta.sdk.jws.JWSError.{InvalidJWSPayload, JWSSignatureExpired, UnconfiguredJWS} +import com.nimbusds.jose.crypto.{RSASSASigner, RSASSAVerifier} +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jose.util.JSONObjectUtils +import com.nimbusds.jose.{JWSAlgorithm, JWSHeader, JWSObjectJSON, Payload} +import io.circe.{parser, Json, Printer} + +import scala.concurrent.duration.FiniteDuration +import scala.jdk.CollectionConverters.ListHasAsScala + +sealed trait JWSPayloadHelper { + + def sign(payloadToSign: Json): IO[Json] + + def verify(payload: Json): IO[Json] +} + +object JWSPayloadHelper { + + final object JWSDisabled extends JWSPayloadHelper { + + override def sign(payloadToSign: Json): IO[Json] = IO.raiseError(UnconfiguredJWS) + + override def verify(payload: Json): IO[Json] = IO.raiseError(UnconfiguredJWS) + } + + final class JWSPayloadHelperImpl(key: RSAKey, tokenValidity: FiniteDuration)(implicit clock: Clock[IO]) + extends JWSPayloadHelper { + private val signer = new RSASSASigner(key) + private val verifier = new RSASSAVerifier(key.toPublicJWK) + private val TokenValiditySeconds = tokenValidity.toSeconds + private val log = Logger[JWSPayloadHelper] + + def sign(payloadToSign: Json): IO[Json] = + for { + now <- clock.realTimeInstant + jwsObject = mkJWSObject(payloadToSign) + _ <- IO.delay(jwsObject.sign(mkJWSHeader(now.getEpochSecond + TokenValiditySeconds), signer)) + serialized <- IO.delay(jwsObject.serializeFlattened()) + json <- IO.fromEither(parser.parse(serialized)) + } yield json + + def verify(payload: Json): IO[Json] = + for { + jwsObject <- IO.delay(JWSObjectJSON.parse(payload.toString())) + sig <- IO.fromOption(jwsObject.getSignatures.asScala.headOption)(InvalidJWSPayload) + _ <- IO.delay(sig.verify(verifier)) + objectPayload = jwsObject.getPayload.toString + originalPayload <- IO.fromEither(parser.parse(objectPayload)) + _ <- log.info(s"Original payload parsed for token: $originalPayload") + now <- clock.realTimeInstant + exp <- IO.delay(sig.getHeader.getCustomParam("exp").asInstanceOf[Long]) + _ <- IO.raiseWhen(now.getEpochSecond > exp)(JWSSignatureExpired) + } yield originalPayload + + private def mkJWSHeader(expSeconds: Long): JWSHeader = + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.getKeyID).customParam("exp", expSeconds).build() + + private def mkJWSObject(payload: Json) = new JWSObjectJSON(mkPayload(payload)) + + private def mkPayload(raw: Json) = { + val jsonObjectMap = JSONObjectUtils.parse(raw.printWith(Printer.noSpacesSortKeys)) + new Payload(jsonObjectMap) + } + } + def apply(config: JWSConfig): JWSPayloadHelper = config match { + case JWSConfig.Disabled => JWSDisabled + case enabled: JWSConfig.Enabled => new JWSPayloadHelperImpl(enabled.rsaKey, enabled.ttl) + } +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/RSAUtils.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/RSAUtils.scala new file mode 100644 index 0000000000..5e233f94a7 --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/RSAUtils.scala @@ -0,0 +1,32 @@ +package ch.epfl.bluebrain.nexus.delta.plugins.storage.jws + +import com.nimbusds.jose.jwk.RSAKey + +import java.security.KeyFactory +import java.security.interfaces.{RSAPrivateCrtKey, RSAPublicKey} +import java.security.spec.{PKCS8EncodedKeySpec, RSAPublicKeySpec} +import java.util.Base64 +import scala.util.Try + +object RSAUtils { + + def parseRSAPrivateKey(raw: String): Try[RSAPrivateCrtKey] = Try { + val keyStripped = raw + .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("\n", "") + val keyStrippedDecoded = Base64.getDecoder.decode(keyStripped) + + val keySpec = new PKCS8EncodedKeySpec(keyStrippedDecoded) + val kf = KeyFactory.getInstance("RSA") + kf.generatePrivate(keySpec).asInstanceOf[RSAPrivateCrtKey] + } + + def generateRSAKeyFromPrivate(privateKey: RSAPrivateCrtKey): RSAKey = { + val publicKeySpec: RSAPublicKeySpec = new RSAPublicKeySpec(privateKey.getModulus, privateKey.getPublicExponent) + val kf = KeyFactory.getInstance("RSA") + val publicKey = kf.generatePublic(publicKeySpec).asInstanceOf[RSAPublicKey] + new RSAKey.Builder(publicKey).privateKey(privateKey).build() + } + +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/RdfExceptionHandler.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/RdfExceptionHandler.scala index e16e035c24..d76c74bcc6 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/RdfExceptionHandler.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/RdfExceptionHandler.scala @@ -16,6 +16,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaDirectives._ import ch.epfl.bluebrain.nexus.delta.sdk.error.ServiceError.AuthorizationFailed import ch.epfl.bluebrain.nexus.delta.sdk.error.{AuthTokenError, IdentityError, ServiceError} import ch.epfl.bluebrain.nexus.delta.sdk.jsonld.JsonLdRejection +import ch.epfl.bluebrain.nexus.delta.sdk.jws.JWSError import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.PermissionsRejection @@ -38,6 +39,7 @@ object RdfExceptionHandler { ): ExceptionHandler = ExceptionHandler { case err: IdentityError => discardEntityAndForceEmit(err) + case err: JWSError => discardEntityAndForceEmit(err) case err: PermissionsRejection => discardEntityAndForceEmit(err) case err: OrganizationRejection => discardEntityAndForceEmit(err) case err: ProjectRejection => discardEntityAndForceEmit(err) diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/TokenIssuerSuite.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSPayloadHelperSuite.scala similarity index 70% rename from delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/TokenIssuerSuite.scala rename to delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSPayloadHelperSuite.scala index a5bf9591c6..6b1d4c5a82 100644 --- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/TokenIssuerSuite.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/jws/JWSPayloadHelperSuite.scala @@ -1,10 +1,9 @@ -package ch.epfl.bluebrain.nexus.delta.plugins.storage.files.routes +package ch.epfl.bluebrain.nexus.delta.sdk.jws -import akka.http.scaladsl.model.Uri import cats.effect.IO -import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileRejection.JWSSignatureExpired -import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.routes.DelegateFilesRoutes.DelegationResponse -import ch.epfl.bluebrain.nexus.delta.rdf.syntax.iriStringContextSyntax +import ch.epfl.bluebrain.nexus.delta.plugins.storage.jws.RSAUtils +import ch.epfl.bluebrain.nexus.delta.sdk.jws.JWSError.JWSSignatureExpired +import ch.epfl.bluebrain.nexus.delta.sdk.jws.JWSPayloadHelper.JWSPayloadHelperImpl import ch.epfl.bluebrain.nexus.testkit.Generators import com.nimbusds.jose.jwk.RSAKey import com.nimbusds.jose.jwk.gen.RSAKeyGenerator @@ -16,17 +15,17 @@ import munit.CatsEffectSuite import java.util.Base64 import scala.concurrent.duration.DurationInt -class TokenIssuerSuite extends CatsEffectSuite with Generators { +class JWSPayloadHelperSuite extends CatsEffectSuite with Generators { val rsaJWK: RSAKey = new RSAKeyGenerator(2048).generate() test("JWS verification successfully round trips for identical payloads") { - val tokenIssuer = new TokenIssuer(rsaJWK, tokenValidity = 100.seconds) + val helper = new JWSPayloadHelperImpl(rsaJWK, tokenValidity = 100.seconds) val returnedPayload = genPayload() for { - jwsPayload <- tokenIssuer.issueJWSPayload(returnedPayload.asJson) - parsedSignedPayload <- tokenIssuer.verifyJWSPayload(jwsPayload) + jwsPayload <- helper.sign(returnedPayload.asJson) + parsedSignedPayload <- helper.verify(jwsPayload) } yield assertEquals(parsedSignedPayload, returnedPayload.asJson) } @@ -40,16 +39,16 @@ class TokenIssuerSuite extends CatsEffectSuite with Generators { """ test("JWS verification fails if token is expired") { - val tokenIssuer = new TokenIssuer(rsaJWK, tokenValidity = 0.seconds) - val returnedPayload = DelegationResponse(genString(), iri"potato", Uri(genString()), None, None) + val helper = new JWSPayloadHelperImpl(rsaJWK, tokenValidity = 0.seconds) + val payloadToSign = json"""{ "content": "Some content"}""" val program = for { - jwsPayload <- tokenIssuer.issueJWSPayload(returnedPayload.asJson) + jwsPayload <- helper.sign(payloadToSign) _ <- IO.sleep(1.second) - _ <- tokenIssuer.verifyJWSPayload(jwsPayload) + _ <- helper.verify(jwsPayload) } yield () - program.intercept[JWSSignatureExpired] + program.intercept[JWSSignatureExpired.type] } test("Parsing RSA private key and JWS verification succeed") { @@ -59,11 +58,11 @@ class TokenIssuerSuite extends CatsEffectSuite with Generators { val returnedPayload = genPayload() for { - parsedPrivateKey <- IO.fromTry(TokenIssuer.parseRSAPrivateKey(rawKey)) - rsaKey = TokenIssuer.generateRSAKeyFromPrivate(parsedPrivateKey) - tokenIssuer = new TokenIssuer(rsaKey, tokenValidity = 100.seconds) - jwsPayload <- tokenIssuer.issueJWSPayload(returnedPayload.asJson) - _ <- tokenIssuer.verifyJWSPayload(jwsPayload) + parsedPrivateKey <- IO.fromTry(RSAUtils.parseRSAPrivateKey(rawKey)) + rsaKey = RSAUtils.generateRSAKeyFromPrivate(parsedPrivateKey) + tokenIssuer = new JWSPayloadHelperImpl(rsaKey, tokenValidity = 100.seconds) + jwsPayload <- tokenIssuer.sign(returnedPayload.asJson) + _ <- tokenIssuer.verify(jwsPayload) } yield () } } diff --git a/ship/src/test/scala/ch/epfl/bluebrain/nexus/ship/config/ShipConfigFixtures.scala b/ship/src/test/scala/ch/epfl/bluebrain/nexus/ship/config/ShipConfigFixtures.scala index 1a691fd331..dddfbe6bd6 100644 --- a/ship/src/test/scala/ch/epfl/bluebrain/nexus/ship/config/ShipConfigFixtures.scala +++ b/ship/src/test/scala/ch/epfl/bluebrain/nexus/ship/config/ShipConfigFixtures.scala @@ -43,8 +43,7 @@ trait ShipConfigFixtures extends ConfigFixtures with StorageFixtures with Classp showLocation = true, 10737418240L, defaultBucket = targetBucket, - prefix = None, - delegation = None + prefix = None ) def inputConfig: InputConfig = diff --git a/tests/docker/config/delta-postgres.conf b/tests/docker/config/delta-postgres.conf index 1f12bc4058..31002aa6d7 100644 --- a/tests/docker/config/delta-postgres.conf +++ b/tests/docker/config/delta-postgres.conf @@ -52,6 +52,12 @@ app { enable-redirects = true } + jws { + type = "enabled" + private-key = "-----BEGIN PRIVATE KEY-----MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDuzdiUC+Y74AfKSDO0hNiYacIdwz67Oevlhl8RiIq7A6kbnAvv/BC18EqzVTj8XKigW2fo9z1VNYQSHnvoZtqFsH+M4SD3cqg1KkpWniLNUlYQJ7jBKex8+3nJdjlzXRb+UWHTbrWVFsKW/p2bUOzBqmaxW3odW1IIRxSdTRTZkZ1+F49FG64fdNJxc47LQL+88KA/b1HHondZ7HljM9zrokmEH0h8qFyovdTuSdlTUA4d8ELoQQ1657eerbTjEJLJUQzy7ijob0jHE1s/CbbKGTgU1/S02v4ZIfdLRmuN1OPGT/Egh3cwv5YpG9dQwpx7qV0HVZd2F+AGFLgKvli3AgMBAAECggEAPF7duc+oTMq+LW1DZQeQmjdiU5PgcAScllH6Bry2FcE/JzOz8N+Qee5ddCi/V001dBSnmEWow7nbwZjjSkV2SQXtuPfRFb1uuMIQOQWRVsbR6xOfqXWny5DnoP66V2fZQEHisUjzrtUqLHIB9hnnQK6Ld5rgrDtB6cXOeFXcR4QDgLuZop/CN7yCOuSnlGLhlNIK+pyA7KhvkWZBvPheGJIbLubG+Lv4ISeo2v5H5M7JFmsAWX9Rc6Mqs258ILxK34NuhFt7heb1+gF3Ppe2O2i+KZ5gxJv2GDx9W92xLMF093mbGxo0PfF/whNsLlKGGd32nadCAfMSmLVAXUIdAQKBgQD4KeJQuwc7nOG1E2Zrh/dzl6GiqrNJZoHBG+iQyU8KwSpWRE/Tk5Ej0JWCBxBM/VNx/qge4pXeEs/vu+aZNj25buHhfA6mrCFLBWit/0Albl9dcJRTb+FEEmojiXaJZLw3TTxFxWKEXji8YWqB7ECHW02nvKrxAdjAD6W4elxlkQKBgQD2WE0QlU6X+OQM0AniuK3y5xNpq10LVAEAgSuKHKR+1kkqvEHUl/S4lGvojlx8V502zcIqIhrC1G3XHJwZF17Ybd4Uec8R+YQSwW2X3Qml1GMN53BXLgjD+VP8a3vfhuCHARJks3HYXf7py1oXMF0tiJ+v66RZ63QVhVTGqe6VxwKBgQCrv+kWsGowEsKPHK8cqsxSntXKC9Prb9tLd/I8Cmb+7XNMoxiQOKgRnnFqvVLFxelzkqhuP6kzOdfZdjUBQm3zoU8JTF+jcKvWDRdGnMqbXUj5FUpCeMLx5sC4eZGlQyeUKosVSqeFLuSbU9xvsL90LnePKF8yT3HgcrPh+iVqUQKBgQCJp0vg4V2ahCSCmFl9zC6/Vao+WNhUNSueKY+3zEuK6JjX/XxXnFXOMnmd6Lb7cEXUUuOVgZsslWGPW1hKmQmRrMr07B/ublwD0vw3aPc0J9r18QaQYJPbVl485Z7Bh++84Ldzd+Y8vkFsSQpdfNQEVpzMw8MB0BT81ZVKsbg1DwKBgQCTrtp+IzECCHR0yALElwREDjRLnQcc/Omnqlp6U9g/34GyFdKuJ/q9jZ0BteakveaunqI6rJbszn07UfKikaB+i3DmLzZadoAb2f/jSThAe/bR606wtlLUA4IEj3oZhXT+neSEboFK1IvRqGmKUrSWMIpJ/fjWKnS0hAoHbqdmpg==-----END PRIVATE KEY-----" + ttl = "3 days" + } + service-account { subject: "service-account-delta" realm: "internal" @@ -151,10 +157,6 @@ plugins { default-secret-key = "CHUTCHUT" default-bucket = "mydefaultbucket" prefix = "myprefix" - delegation { - private-key = "-----BEGIN PRIVATE KEY-----MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDuzdiUC+Y74AfKSDO0hNiYacIdwz67Oevlhl8RiIq7A6kbnAvv/BC18EqzVTj8XKigW2fo9z1VNYQSHnvoZtqFsH+M4SD3cqg1KkpWniLNUlYQJ7jBKex8+3nJdjlzXRb+UWHTbrWVFsKW/p2bUOzBqmaxW3odW1IIRxSdTRTZkZ1+F49FG64fdNJxc47LQL+88KA/b1HHondZ7HljM9zrokmEH0h8qFyovdTuSdlTUA4d8ELoQQ1657eerbTjEJLJUQzy7ijob0jHE1s/CbbKGTgU1/S02v4ZIfdLRmuN1OPGT/Egh3cwv5YpG9dQwpx7qV0HVZd2F+AGFLgKvli3AgMBAAECggEAPF7duc+oTMq+LW1DZQeQmjdiU5PgcAScllH6Bry2FcE/JzOz8N+Qee5ddCi/V001dBSnmEWow7nbwZjjSkV2SQXtuPfRFb1uuMIQOQWRVsbR6xOfqXWny5DnoP66V2fZQEHisUjzrtUqLHIB9hnnQK6Ld5rgrDtB6cXOeFXcR4QDgLuZop/CN7yCOuSnlGLhlNIK+pyA7KhvkWZBvPheGJIbLubG+Lv4ISeo2v5H5M7JFmsAWX9Rc6Mqs258ILxK34NuhFt7heb1+gF3Ppe2O2i+KZ5gxJv2GDx9W92xLMF093mbGxo0PfF/whNsLlKGGd32nadCAfMSmLVAXUIdAQKBgQD4KeJQuwc7nOG1E2Zrh/dzl6GiqrNJZoHBG+iQyU8KwSpWRE/Tk5Ej0JWCBxBM/VNx/qge4pXeEs/vu+aZNj25buHhfA6mrCFLBWit/0Albl9dcJRTb+FEEmojiXaJZLw3TTxFxWKEXji8YWqB7ECHW02nvKrxAdjAD6W4elxlkQKBgQD2WE0QlU6X+OQM0AniuK3y5xNpq10LVAEAgSuKHKR+1kkqvEHUl/S4lGvojlx8V502zcIqIhrC1G3XHJwZF17Ybd4Uec8R+YQSwW2X3Qml1GMN53BXLgjD+VP8a3vfhuCHARJks3HYXf7py1oXMF0tiJ+v66RZ63QVhVTGqe6VxwKBgQCrv+kWsGowEsKPHK8cqsxSntXKC9Prb9tLd/I8Cmb+7XNMoxiQOKgRnnFqvVLFxelzkqhuP6kzOdfZdjUBQm3zoU8JTF+jcKvWDRdGnMqbXUj5FUpCeMLx5sC4eZGlQyeUKosVSqeFLuSbU9xvsL90LnePKF8yT3HgcrPh+iVqUQKBgQCJp0vg4V2ahCSCmFl9zC6/Vao+WNhUNSueKY+3zEuK6JjX/XxXnFXOMnmd6Lb7cEXUUuOVgZsslWGPW1hKmQmRrMr07B/ublwD0vw3aPc0J9r18QaQYJPbVl485Z7Bh++84Ldzd+Y8vkFsSQpdfNQEVpzMw8MB0BT81ZVKsbg1DwKBgQCTrtp+IzECCHR0yALElwREDjRLnQcc/Omnqlp6U9g/34GyFdKuJ/q9jZ0BteakveaunqI6rJbszn07UfKikaB+i3DmLzZadoAb2f/jSThAe/bR606wtlLUA4IEj3oZhXT+neSEboFK1IvRqGmKUrSWMIpJ/fjWKnS0hAoHbqdmpg==-----END PRIVATE KEY-----" - token-duration = "3 days" - } } }