diff --git a/README.md b/README.md index 1fdaa12..328804a 100644 --- a/README.md +++ b/README.md @@ -155,20 +155,27 @@ publicKey=example_key * **publicKey** - this is the public key used to verify the cookie -### Generating Keys +### Rotating Keys -**Guardian Devs**: See the [Emergency Panda key-rotation Guide](https://docs.google.com/document/d/1haVnQ9D8zNYUU-fOfkudPC1WpPGrlelLygd8V7xb3eQ/edit?usp=sharing). +**Guardian Devs**: See the [Panda key-rotation Guide](https://docs.google.com/document/d/1haVnQ9D8zNYUU-fOfkudPC1WpPGrlelLygd8V7xb3eQ/edit?usp=sharing) +for Guardian-specific details of where config details are stored, etc. -You can generate an rsa key pair as follows: +To avoid disruption to users, rotating keys requires 3 distinct settings updates, with pauses between each one. First +obtain a copy of the current settings file (eg `current-from-s3.settings`), then use the sbt console to run +the `CryptoConfForRotation` Scala script on that `.settings` file to generate a new RSA 4096 keypair and the new +required config files for each step: - openssl genrsa -out private_key.pem 4096 - openssl rsa -pubout -in private_key.pem -out public_key.pem - -There is a helper script in the root of this project that uses the commands above and outputs a new keypair in the format used by the panda settings file: +``` +pan-domain-auth-verification / Test / runMain com.gu.pandomainauth.CryptoConfForRotation current-from-s3.settings +``` - ./generateKeyPair.sh +3 new partial `.settings` files will be created, providing _just_ the updated crypto settings - you'll need to +edit them into the existing `current-from-s3.settings` & `current-from-s3.settings.public` files before uploading +those updates: -Note: you only need to pass the key ie the blob of base64 between the start and end markers in the pem file. +* 1.rotation-upcoming.settings - give this 2 minutes of settling time +* 2.rotation-in-progress.settings - give this at least 1 hour of settling time +* 3.rotation-complete.settings ## Integrating with your Scala app diff --git a/build.sbt b/build.sbt index 1ac5c09..129d9c4 100644 --- a/build.sbt +++ b/build.sbt @@ -22,7 +22,7 @@ val commonSettings = "-deprecation", // upgrade warnings to errors except deprecations "-Wconf:cat=deprecation:ws,any:e", - "-release:8" + "-release:11" ), licenses := Seq(License.Apache2), ) diff --git a/generateKeyPair.sh b/generateKeyPair.sh deleted file mode 100755 index 1f1c892..0000000 --- a/generateKeyPair.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -set -e - -PRIVATE_KEY_FILE=$(mktemp /tmp/private-key.XXXXXX) -PUBLIC_KEY_FILE=$(mktemp /tmp/public-key.XXXXXX) - -function on_exit() { - rm -f ${PRIVATE_KEY_FILE} - rm -f ${PUBLIC_KEY_FILE} -} - -trap on_exit EXIT - -openssl genrsa -out ${PRIVATE_KEY_FILE} 4096 -openssl rsa -pubout -in ${PRIVATE_KEY_FILE} -out ${PUBLIC_KEY_FILE} - -TRIMMED_PRIVATE_KEY=`cat ${PRIVATE_KEY_FILE} | sed -e '1d' -e '$d' | tr -d '\n'` -TRIMMED_PUBLIC_KEY=`cat ${PUBLIC_KEY_FILE} | sed -e '1d' -e '$d' | tr -d '\n'` - -echo privateKey=${TRIMMED_PRIVATE_KEY} -echo publicKey=${TRIMMED_PUBLIC_KEY} diff --git a/pan-domain-auth-core/src/main/scala/com/gu/pandomainauth/PanDomainAuthSettingsRefresher.scala b/pan-domain-auth-core/src/main/scala/com/gu/pandomainauth/PanDomainAuthSettingsRefresher.scala index 13d8608..fe28101 100644 --- a/pan-domain-auth-core/src/main/scala/com/gu/pandomainauth/PanDomainAuthSettingsRefresher.scala +++ b/pan-domain-auth-core/src/main/scala/com/gu/pandomainauth/PanDomainAuthSettingsRefresher.scala @@ -35,9 +35,10 @@ class PanDomainAuthSettingsRefresher( private val settingsRefresher = new Settings.Refresher[PanDomainAuthSettings]( new Settings.Loader(s3BucketLoader, settingsFileKey), PanDomainAuthSettings.apply, + _.signingAndVerification, scheduler ) - settingsRefresher.start(1) + settingsRefresher.start() def settings: PanDomainAuthSettings = settingsRefresher.get() } diff --git a/pan-domain-auth-core/src/main/scala/com/gu/pandomainauth/model/PanDomainAuthSettings.scala b/pan-domain-auth-core/src/main/scala/com/gu/pandomainauth/model/PanDomainAuthSettings.scala index 478576b..a771315 100644 --- a/pan-domain-auth-core/src/main/scala/com/gu/pandomainauth/model/PanDomainAuthSettings.scala +++ b/pan-domain-auth-core/src/main/scala/com/gu/pandomainauth/model/PanDomainAuthSettings.scala @@ -1,10 +1,11 @@ package com.gu.pandomainauth.model -import com.gu.pandomainauth.SettingsFailure.SettingsResult -import com.gu.pandomainauth.service.{CryptoConf, KeyPair} +import com.gu.pandomainauth.Settings.SettingsResult +import com.gu.pandomainauth.service.CryptoConf +import com.gu.pandomainauth.service.CryptoConf.SigningAndVerification case class PanDomainAuthSettings( - signingKeyPair: KeyPair, + signingAndVerification: SigningAndVerification, cookieSettings: CookieSettings, oAuthSettings: OAuthSettings, google2FAGroupSettings: Option[Google2FAGroupSettings] @@ -51,9 +52,9 @@ object PanDomainAuthSettings{ ) yield Google2FAGroupSettings(serviceAccountId, serviceAccountCert, adminUser, group) for { - activeKeyPair <- CryptoConf.SettingsReader(settingMap).activeKeyPair + cryptoConf <- CryptoConf.SettingsReader(settingMap).signingAndVerificationConf } yield PanDomainAuthSettings( - activeKeyPair, + cryptoConf, cookieSettings, oAuthSettings, google2faSettings diff --git a/pan-domain-auth-example/app/VerifyExample.scala b/pan-domain-auth-example/app/VerifyExample.scala index 2c2a470..05253da 100644 --- a/pan-domain-auth-example/app/VerifyExample.scala +++ b/pan-domain-auth-example/app/VerifyExample.scala @@ -3,6 +3,7 @@ import com.amazonaws.regions.Regions import com.amazonaws.services.s3.AmazonS3ClientBuilder import com.gu.pandomainauth.S3BucketLoader.forAwsSdkV1 import com.gu.pandomainauth.model.{Authenticated, AuthenticatedUser, GracePeriod} +import com.gu.pandomainauth.service.CryptoConf import com.gu.pandomainauth.{PanDomain, PublicSettings, Settings} object VerifyExample { @@ -21,7 +22,7 @@ object VerifyExample { // Call the start method when your application starts up to ensure the settings are kept up to date publicSettings.start() - val publicKey = publicSettings.publicKey + val verification: CryptoConf.Verification = publicSettings.verification // The name of this particular application val system = "test" @@ -42,7 +43,7 @@ object VerifyExample { val cacheValidation = false // To verify, call the authStatus method with the encoded cookie data - val status = PanDomain.authStatus("<>>", publicKey, validateUser, apiGracePeriod, system, cacheValidation, forceExpiry = false) + val status = PanDomain.authStatus("<>>", verification, validateUser, apiGracePeriod, system, cacheValidation, forceExpiry = false) status match { case Authenticated(_) | GracePeriod(_) => diff --git a/pan-domain-auth-play/src/main/scala/com/gu/pandomainauth/action/Actions.scala b/pan-domain-auth-play/src/main/scala/com/gu/pandomainauth/action/Actions.scala index 718ec56..0aa96f5 100644 --- a/pan-domain-auth-play/src/main/scala/com/gu/pandomainauth/action/Actions.scala +++ b/pan-domain-auth-play/src/main/scala/com/gu/pandomainauth/action/Actions.scala @@ -8,9 +8,8 @@ import play.api.libs.ws.WSClient import play.api.mvc.Results._ import play.api.mvc._ +import java.net.{URLDecoder, URLEncoder} import scala.concurrent.{ExecutionContext, Future} -import java.net.URLEncoder -import java.net.URLDecoder class UserRequest[A](val user: User, request: Request[A]) extends WrappedRequest[A](request) @@ -198,7 +197,7 @@ trait AuthActions { } def readAuthenticatedUser(request: RequestHeader): Option[AuthenticatedUser] = readCookie(request) flatMap { cookie => - CookieUtils.parseCookieData(cookie.cookie.value, settings.signingKeyPair.publicKey).toOption + CookieUtils.parseCookieData(cookie.cookie.value, settings.signingAndVerification).toOption } def readCookie(request: RequestHeader): Option[PandomainCookie] = { @@ -208,14 +207,13 @@ trait AuthActions { } } - def generateCookie(authedUser: AuthenticatedUser): Cookie = - Cookie( - name = settings.cookieSettings.cookieName, - value = CookieUtils.generateCookieData(authedUser, settings.signingKeyPair.privateKey), - domain = Some(domain), - secure = true, - httpOnly = true - ) + def generateCookie(authedUser: AuthenticatedUser): Cookie = Cookie( + name = settings.cookieSettings.cookieName, + value = CookieUtils.generateCookieData(authedUser, settings.signingAndVerification), + domain = Some(domain), + secure = true, + httpOnly = true + ) def includeSystemInCookie(authedUser: AuthenticatedUser)(result: Result): Result = { val updatedAuth = authedUser.copy(authenticatedIn = authedUser.authenticatedIn + system) @@ -237,7 +235,7 @@ trait AuthActions { */ def extractAuth(request: RequestHeader): AuthenticationStatus = { readCookie(request).map { cookie => - PanDomain.authStatus(cookie.cookie.value, settings.signingKeyPair.publicKey, validateUser, apiGracePeriod, system, cacheValidation, cookie.forceExpiry) + PanDomain.authStatus(cookie.cookie.value, settings.signingAndVerification, validateUser, apiGracePeriod, system, cacheValidation, cookie.forceExpiry) } getOrElse NotAuthenticated } diff --git a/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/PanDomain.scala b/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/PanDomain.scala index 7dd8ba1..68ca4a0 100644 --- a/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/PanDomain.scala +++ b/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/PanDomain.scala @@ -2,17 +2,16 @@ package com.gu.pandomainauth import com.gu.pandomainauth.model._ import com.gu.pandomainauth.service.CookieUtils - -import java.security.PublicKey +import com.gu.pandomainauth.service.CryptoConf.Verification object PanDomain { /** * Check the authentication status of the provided credentials by examining the signed cookie data. */ - def authStatus(cookieData: String, publicKey: PublicKey, validateUser: AuthenticatedUser => Boolean, + def authStatus(cookieData: String, verification: Verification, validateUser: AuthenticatedUser => Boolean, apiGracePeriod: Long, system: String, cacheValidation: Boolean, forceExpiry: Boolean): AuthenticationStatus = { - CookieUtils.parseCookieData(cookieData, publicKey).fold(InvalidCookie, { authedUser => + CookieUtils.parseCookieData(cookieData, verification).fold(InvalidCookie, { authedUser => checkStatus(authedUser, validateUser, apiGracePeriod, system, cacheValidation, forceExpiry) }) } diff --git a/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/PublicSettings.scala b/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/PublicSettings.scala index cdc80ed..e569a7a 100644 --- a/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/PublicSettings.scala +++ b/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/PublicSettings.scala @@ -1,14 +1,15 @@ package com.gu.pandomainauth import com.amazonaws.services.s3.AmazonS3 -import com.gu.pandomainauth.Settings.Loader -import com.gu.pandomainauth.SettingsFailure.SettingsResult +import com.gu.pandomainauth.Settings.{Loader, SettingsResult} import com.gu.pandomainauth.service.CryptoConf +import com.gu.pandomainauth.service.CryptoConf.Verification import java.security.PublicKey +import java.time.Duration +import java.time.Duration.ofMinutes import java.util.concurrent.Executors.newScheduledThreadPool import java.util.concurrent.{Executors, ScheduledExecutorService} -import scala.concurrent.duration._ /** * Class that contains the static public settings and includes mechanism for fetching the public key. Once you have an @@ -21,15 +22,19 @@ class PublicSettings(loader: Settings.Loader, scheduler: ScheduledExecutorServic new Settings.Loader(S3BucketLoader.forAwsSdkV1(s3Client, bucketName), settingsFileKey), scheduler ) - private val settingsRefresher = new Settings.Refresher[PublicKey]( + private val settingsRefresher = new Settings.Refresher[Verification]( loader, - CryptoConf.SettingsReader(_).activePublicKey, + CryptoConf.SettingsReader(_).verificationConf, + identity, scheduler ) - def start(interval: FiniteDuration = 60.seconds): Unit = settingsRefresher.start(interval.toMinutes.toInt) + def start(interval: Duration = ofMinutes(1)): Unit = settingsRefresher.start(interval) - def publicKey: PublicKey = settingsRefresher.get() + def verification: Verification = settingsRefresher.get() + + @deprecated("Use `verification` instead, to allow smooth transition to new public keys") + def publicKey: PublicKey = verification.activePublicKey } /** @@ -40,11 +45,6 @@ object PublicSettings { def apply(loader: Settings.Loader): PublicSettings = new PublicSettings(loader, newScheduledThreadPool(1)) - /** - * Fetches the public key from the public S3 bucket - * - * @param domain the domain to fetch the public key for - */ - def getPublicKey(loader: Loader): SettingsResult[PublicKey] = - loader.loadAndParseSettingsMap().flatMap(CryptoConf.SettingsReader(_).activePublicKey) + def getVerification(loader: Loader): SettingsResult[Verification] = + loader.loadAndParseSettingsMap().flatMap(CryptoConf.SettingsReader(_).verificationConf) } diff --git a/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/Settings.scala b/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/Settings.scala index bfc0bbc..2bec6d8 100644 --- a/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/Settings.scala +++ b/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/Settings.scala @@ -1,12 +1,15 @@ package com.gu.pandomainauth import com.amazonaws.util.IOUtils -import com.gu.pandomainauth.SettingsFailure.SettingsResult +import com.gu.pandomainauth.service.CryptoConf +import com.gu.pandomainauth.service.CryptoConf.Verification import org.slf4j.{Logger, LoggerFactory} import java.io.ByteArrayInputStream +import java.time.Duration +import java.time.Duration.ofMinutes import java.util.Properties -import java.util.concurrent.TimeUnit.MINUTES +import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.{Executors, ScheduledExecutorService} import scala.jdk.CollectionConverters._ @@ -48,11 +51,15 @@ case object InvalidBase64 extends SettingsFailure { override val description: String = "Settings file value for cryptographic key is not valid base64" } -object SettingsFailure { +object Settings { type SettingsResult[A] = Either[SettingsFailure, A] -} -object Settings { + implicit class RichSettingsResultSeq[A](result: Seq[SettingsResult[A]]) { + def sequence: SettingsResult[Seq[A]] = result.foldLeft[SettingsResult[List[A]]](Right(Nil)) { // Easier with Cats! + (acc, e) => for (keys <- acc; key <- e) yield key :: keys + } + } + /** * @param settingsFileKey the name of the file that contains the private settings for the given domain */ @@ -77,6 +84,7 @@ object Settings { class Refresher[A]( loader: Settings.Loader, settingsParser: Map[String, String] => SettingsResult[A], + verificationIn: A => Verification, scheduler: ScheduledExecutorService = Executors.newScheduledThreadPool(1) ) { // This is deliberately designed to throw an exception during construction if we cannot immediately read the settings @@ -86,7 +94,10 @@ object Settings { private val logger = LoggerFactory.getLogger(getClass) - def start(interval: Int): Unit = scheduler.scheduleAtFixedRate(() => refresh(), 0, interval, MINUTES) + def start(interval: Duration = ofMinutes(1)): Unit = { + logger.info(s"Starting refresh schedule with an interval of $interval") + scheduler.scheduleAtFixedRate(() => refresh(), 0, interval.toMillis, MILLISECONDS) + } def loadAndParseSettings(): SettingsResult[A] = loader.loadAndParseSettingsMap().flatMap(settingsParser) @@ -94,7 +105,10 @@ object Settings { private def refresh(): Unit = loadAndParseSettings() match { case Right(newSettings) => val oldSettings = store.getAndSet(newSettings) - if (oldSettings != newSettings) logger.info("Updated pan-domain settings") + for (change <- CryptoConf.Change.compare(verificationIn(oldSettings), verificationIn(newSettings))) { + val message = s"Panda settings changed: ${change.summary}" + if (change.isBreakingChange) logger.warn(message) else logger.info(message) + } case Left(err) => logger.error("Failed to update pan-domain settings for $domain") err.logError(logger) diff --git a/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/CookieUtils.scala b/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/CookieUtils.scala index 34941e4..b190e30 100644 --- a/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/CookieUtils.scala +++ b/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/CookieUtils.scala @@ -2,8 +2,8 @@ package com.gu.pandomainauth.service import com.gu.pandomainauth.model.{AuthenticatedUser, User} import com.gu.pandomainauth.service.CookieUtils.CookieIntegrityFailure.{MalformedCookieText, MissingOrMalformedUserData, SignatureNotValid} +import com.gu.pandomainauth.service.CryptoConf.{Signing, Verification} -import java.security.{PrivateKey, PublicKey} import scala.util.Try object CookieUtils { @@ -50,12 +50,12 @@ object CookieUtils { ) } - def generateCookieData(authUser: AuthenticatedUser, prvKey: PrivateKey): String = - CookiePayload.generateForPayloadText(serializeAuthenticatedUser(authUser), prvKey).asCookieText + def generateCookieData(authUser: AuthenticatedUser, signing: Signing): String = + CookiePayload.generateForPayloadText(serializeAuthenticatedUser(authUser), signing.activePrivateKey).asCookieText - def parseCookieData(cookieString: String, publicKey: PublicKey): CookieResult[AuthenticatedUser] = for { + def parseCookieData(cookieString: String, verification: Verification): CookieResult[AuthenticatedUser] = for { cookiePayload <- CookiePayload.parse(cookieString).toRight(MalformedCookieText) - cookiePayloadText <- cookiePayload.payloadTextVerifiedSignedWith(publicKey).toRight(SignatureNotValid) + cookiePayloadText <- verification.decode(cookiePayload.payloadTextVerifiedSignedWith).toRight(SignatureNotValid) authUser <- deserializeAuthenticatedUser(cookiePayloadText).toRight(MissingOrMalformedUserData) } yield authUser } diff --git a/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/Crypto.scala b/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/Crypto.scala index b21059d..07d83b6 100644 --- a/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/Crypto.scala +++ b/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/Crypto.scala @@ -14,14 +14,13 @@ object Crypto { * * Note: you only need to pass the key ie the blob of base64 between the start and end markers in the pem file. */ - Security.addProvider(new BouncyCastleProvider()) - val signatureAlgorithm: String = "SHA256withRSA" val keyFactory = KeyFactory.getInstance("RSA") + private def signatureInstance() = Signature.getInstance("SHA256withRSA", "BC") def signData(data: Array[Byte], prvKey: PrivateKey): Array[Byte] = { - val rsa = Signature.getInstance(signatureAlgorithm, "BC") + val rsa = signatureInstance() rsa.initSign(prvKey) rsa.update(data) @@ -29,7 +28,7 @@ object Crypto { } def verifySignature(data: Array[Byte], signature: Array[Byte], pubKey: PublicKey) : Boolean = { - val rsa = Signature.getInstance(signatureAlgorithm, "BC") + val rsa = signatureInstance() rsa.initVerify(pubKey) rsa.update(data) diff --git a/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/CryptoConf.scala b/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/CryptoConf.scala index 5a74130..7e2c3e2 100644 --- a/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/CryptoConf.scala +++ b/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/CryptoConf.scala @@ -1,6 +1,6 @@ package com.gu.pandomainauth.service -import com.gu.pandomainauth.SettingsFailure.SettingsResult +import com.gu.pandomainauth.Settings._ import com.gu.pandomainauth.service.Crypto.keyFactory import com.gu.pandomainauth.service.CryptoConf.SettingsReader.{privateKeyFor, publicKeyFor} import com.gu.pandomainauth.{InvalidBase64, MissingSetting, PublicKeyFormatFailure} @@ -9,17 +9,53 @@ import org.apache.commons.codec.binary.Base64.{decodeBase64, isBase64} import java.security.spec.{InvalidKeySpecException, KeySpec, PKCS8EncodedKeySpec, X509EncodedKeySpec} import java.security.{KeyFactory, PrivateKey, PublicKey} import scala.util.Try +import scala.collection.compat._ +import immutable.LazyList object CryptoConf { + trait Signing { + val activePrivateKey: PrivateKey + } + + trait Verification { + val activePublicKey: PublicKey + val alsoAccepted: Seq[PublicKey] + + lazy val acceptedPublicKeys: LazyList[PublicKey] = LazyList(activePublicKey) ++ alsoAccepted + + private[CryptoConf] def acceptsActiveKeyFrom(other: Verification): Boolean = acceptedPublicKeys.contains(other.activePublicKey) + + def decode[A](f: PublicKey => Option[A]): Option[A] = acceptedPublicKeys.flatMap(f(_)).headOption + } + + case class SigningAndVerification(activeKeyPair: KeyPair, alsoAccepted: Seq[PublicKey]) extends Signing with Verification { + val activePublicKey: PublicKey = activeKeyPair.publicKey + val activePrivateKey: PrivateKey = activeKeyPair.privateKey + } + + case class OnlyVerification(activePublicKey: PublicKey, alsoAccepted: Seq[PublicKey] = Seq.empty) extends Verification + case class SettingsReader(settingMap: Map[String,String]) { def setting(key: String): SettingsResult[String] = settingMap.get(key).toRight(MissingSetting(key)) + def signingAndVerificationConf: SettingsResult[SigningAndVerification] = makeConfWith(activeKeyPair)(SigningAndVerification) + def verificationConf: SettingsResult[Verification] = makeConfWith(activePublicKey)(OnlyVerification) + val activePublicKey: SettingsResult[PublicKey] = setting("publicKey").flatMap(publicKeyFor) - def activeKeyPair: SettingsResult[KeyPair] = for { + private val alsoAcceptedPublicKeys: SettingsResult[Seq[PublicKey]] = settingMap.collect { + case (k, v) if k.startsWith("alsoAccept.") && k.endsWith(".publicKey") => publicKeyFor(v) + }.toSeq.sequence + + private def activeKeyPair: SettingsResult[KeyPair] = for { publicKey <- activePublicKey privateKey <- setting("privateKey").flatMap(privateKeyFor) } yield KeyPair(publicKey, privateKey) + + private def makeConfWith[A, T](activePartResult: SettingsResult[A])(createConf: (A, Seq[PublicKey]) => T): SettingsResult[T] = for { + activePart <- activePartResult + alsoAccepted <- alsoAcceptedPublicKeys + } yield createConf(activePart, alsoAccepted) } object SettingsReader { @@ -42,4 +78,41 @@ object CryptoConf { def privateKeyFor(base64Key: String): SettingsResult[PrivateKey] = keyFor(base64Key, new PKCS8EncodedKeySpec(_), _.generatePrivate) } + + object Change { + def compare(oldConf: Verification, newConf: Verification): Option[CryptoConf.Change] = + Option.when(newConf != oldConf)(Change( + activeKey = Option.when(newConf.activePublicKey != oldConf.activePublicKey)(ActiveKey( + toleratingOldKey = newConf.acceptsActiveKeyFrom(oldConf), + newKeyAlreadyAccepted = oldConf.acceptsActiveKeyFrom(newConf) + )), + SeqDiff.compare(oldConf.alsoAccepted, newConf.alsoAccepted) + )) + + /** + * CryptoConf.Change.ActiveKey details the consequences of a change to the active key, + * allowing us to know if the change could disrupt existing user sessions. + */ + case class ActiveKey(toleratingOldKey: Boolean, newKeyAlreadyAccepted: Boolean) { + val isBreakingChange: Boolean = !(toleratingOldKey && newKeyAlreadyAccepted) + val summary: String = s"Active key changed: ${if (isBreakingChange) s"BREAKING - old-tolerated=$toleratingOldKey new-already-accepted=$newKeyAlreadyAccepted" else "non-breaking"}" + } + } + + /** + * CryptoConf.Change denotes that there's been a change to the crypto settings. If the active key + * has changed, we'll have a CryptoConf.Change.ActiveKey detailing if the update is safe. + */ + case class Change(activeKey: Option[Change.ActiveKey], acceptedKeys: SeqDiff[PublicKey]) { + val isBreakingChange: Boolean = activeKey.exists(_.isBreakingChange) + val summary: String = (activeKey.map(_.summary).toSeq :+ s"acceptedKeys: ${acceptedKeys.summary}").mkString(" ") + } + + case class SeqDiff[T](added: Seq[T], removed: Seq[T]) { + val summary: String = s"added ${added.size}, removed ${removed.size}" + } + object SeqDiff { + def compare[T](oldItems: Seq[T], newItems: Seq[T]): SeqDiff[T] = + SeqDiff(added = newItems.diff(oldItems), removed = oldItems.diff(newItems)) + } } \ No newline at end of file diff --git a/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/KeyPair.scala b/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/KeyPair.scala index b2d6dc9..02df87a 100644 --- a/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/KeyPair.scala +++ b/pan-domain-auth-verification/src/main/scala/com/gu/pandomainauth/service/KeyPair.scala @@ -3,7 +3,7 @@ package com.gu.pandomainauth.service import java.security.{PrivateKey, PublicKey} /** - * This class mainly exists because java.security.KeyPair does not implement a useful `.equals()`` method, + * This class mainly exists because java.security.KeyPair does not implement a useful `.equals()` method, * and we're going to want to be able to check whether two key-pairs are equal. */ case class KeyPair(publicKey: PublicKey, privateKey: PrivateKey) diff --git a/pan-domain-auth-verification/src/test/resources/crypto-conf-rotation-example/0.legacy.settings b/pan-domain-auth-verification/src/test/resources/crypto-conf-rotation-example/0.legacy.settings new file mode 100644 index 0000000..257ab52 --- /dev/null +++ b/pan-domain-auth-verification/src/test/resources/crypto-conf-rotation-example/0.legacy.settings @@ -0,0 +1,2 @@ +privateKey=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 +publicKey=MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtlvGdaBPy+eRfntUlaJO1tS4beLJ+OIj+xE0awKpnTPdt8fbLbR+PYwYLlB2sWl4tkF0V0GIZJgb3Y2ruqICNN7HLcJTlR+G9TDmF+Hu+GlCYlnHeivlQidTtC1WLXuDO2EBzQDkXzfeeP0QdtyoYqlPe1Vpt9ksTzTspBTM7VJ/10ot8OAuHThn611P6lT8QBG0MzHb/XXjGr3JqZdl4xer78lQrqnmjy3UlusgDIiyRo3/HcMyxPmWpgszLvcOHG2qQ8JVpvMEfkukB2ROSamGNy9zKAW9rqnzMyHTcrhPDlapeMUIdCSsyOT3Hh8yQT/aIR+7e48PUYrC3e2nrn8QrsebE6YC0s3wPnyH5V9u0X7UY/34B1C7nb22boOfGrMexJ3Pvy9bvdL1w80Ob/IPm7OBH8PgcJ1UzlrYnIIt6ddr5nvLvkBPgkQsiYuwEwFUQJu89NhVH/JsV35B2TycJFGdd7fMyjLGC2apMFgVvN+kautcB5+I+bcWq0/3+O7FK50IeIRzWx44xU5tyGGAhwTRMqrPlW8kg8au2Mm2MitrlkRQ3MYH8ajmUyhZKAmOMH3S6/y/fn4g4ABx7sv7vKTSwLvjCpiLNx5FxF4i0I9ZLRCr8WW3D2EqpQwJhMPL6BdhPeJ0bx2UdY2+y8L7QgerrkUKMCIej9KdqhkCAwEAAQ== diff --git a/pan-domain-auth-verification/src/test/resources/crypto-conf-rotation-example/1.rotation-upcoming.settings b/pan-domain-auth-verification/src/test/resources/crypto-conf-rotation-example/1.rotation-upcoming.settings new file mode 100644 index 0000000..fed22eb --- /dev/null +++ b/pan-domain-auth-verification/src/test/resources/crypto-conf-rotation-example/1.rotation-upcoming.settings @@ -0,0 +1,7 @@ +# 1.rotation-upcoming.settings - Generated at 2024-09-11T16:10:11Z + +privateKey=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 + +publicKey=MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtlvGdaBPy+eRfntUlaJO1tS4beLJ+OIj+xE0awKpnTPdt8fbLbR+PYwYLlB2sWl4tkF0V0GIZJgb3Y2ruqICNN7HLcJTlR+G9TDmF+Hu+GlCYlnHeivlQidTtC1WLXuDO2EBzQDkXzfeeP0QdtyoYqlPe1Vpt9ksTzTspBTM7VJ/10ot8OAuHThn611P6lT8QBG0MzHb/XXjGr3JqZdl4xer78lQrqnmjy3UlusgDIiyRo3/HcMyxPmWpgszLvcOHG2qQ8JVpvMEfkukB2ROSamGNy9zKAW9rqnzMyHTcrhPDlapeMUIdCSsyOT3Hh8yQT/aIR+7e48PUYrC3e2nrn8QrsebE6YC0s3wPnyH5V9u0X7UY/34B1C7nb22boOfGrMexJ3Pvy9bvdL1w80Ob/IPm7OBH8PgcJ1UzlrYnIIt6ddr5nvLvkBPgkQsiYuwEwFUQJu89NhVH/JsV35B2TycJFGdd7fMyjLGC2apMFgVvN+kautcB5+I+bcWq0/3+O7FK50IeIRzWx44xU5tyGGAhwTRMqrPlW8kg8au2Mm2MitrlkRQ3MYH8ajmUyhZKAmOMH3S6/y/fn4g4ABx7sv7vKTSwLvjCpiLNx5FxF4i0I9ZLRCr8WW3D2EqpQwJhMPL6BdhPeJ0bx2UdY2+y8L7QgerrkUKMCIej9KdqhkCAwEAAQ== + +alsoAccept.0.publicKey=MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArmt3/bxQ6qdgaUHApX0M7wdwyCaa0fTeqOh0xi4ShCrNcWBvIUlKVJ2E9+FYLM7KU79szhi5odQ/cqBxpbBvTqDj7d/0LXSRkiNtAdZAt2yPxfrQ/MX36Uxm1TcfmpY9jxWcVb91s4L67uydwN3sB8NpyFayHZHkNrwdn/eT86ie9YRiRuAWu8j8P6KhNzUxm3JvWkMTRDT2YQvMpZ0kYQ+8G/Q54dXUGpu9BewRgUwU9avmeI12pKM2MiKBM7rp/i//BSH7++KCxZvcHbzjVcLaR7qMVpjDgBqy13uPSq0V/S5M3NV+Uq7nasZh/Q9o/cF2VaMMAIu6TNYAVOQDgP54lQ8IY5kzsdlw9RuL8oUrAlHx1ZpcvQYfXSawOwklXY8oe7uEmCKprsOluHA4hlDUmtztySU5EGoGtN1kUX/oIl0ZJMkrHcRt0ryE+LGcCteaVryY97DpFISSDxl2OEyn02yEYKYgRehPPgZ3r38kT2v1oYy77dJrFanVSdYCUQTEIQKn/UPo2G70rbL1+MQ/gKuz81Ox9b2eVKupV8aRKuGRVSP+SmvmSEEJv8sDnupZbvY7RvUkVQd9yTw2ZzsKLXER9olaVtP4f7ovLba8wcELHXfQ5figHBPybqdQ8eEBa038xiURtPPLz1YapQFqVmgGWwjqqo8Y8/Tm+tcCAwEAAQ== diff --git a/pan-domain-auth-verification/src/test/resources/crypto-conf-rotation-example/2.rotation-in-progress.settings b/pan-domain-auth-verification/src/test/resources/crypto-conf-rotation-example/2.rotation-in-progress.settings new file mode 100644 index 0000000..fced507 --- /dev/null +++ b/pan-domain-auth-verification/src/test/resources/crypto-conf-rotation-example/2.rotation-in-progress.settings @@ -0,0 +1,7 @@ +# 2.rotation-in-progress.settings - Generated at 2024-09-11T16:10:11Z + +privateKey=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 + +publicKey=MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArmt3/bxQ6qdgaUHApX0M7wdwyCaa0fTeqOh0xi4ShCrNcWBvIUlKVJ2E9+FYLM7KU79szhi5odQ/cqBxpbBvTqDj7d/0LXSRkiNtAdZAt2yPxfrQ/MX36Uxm1TcfmpY9jxWcVb91s4L67uydwN3sB8NpyFayHZHkNrwdn/eT86ie9YRiRuAWu8j8P6KhNzUxm3JvWkMTRDT2YQvMpZ0kYQ+8G/Q54dXUGpu9BewRgUwU9avmeI12pKM2MiKBM7rp/i//BSH7++KCxZvcHbzjVcLaR7qMVpjDgBqy13uPSq0V/S5M3NV+Uq7nasZh/Q9o/cF2VaMMAIu6TNYAVOQDgP54lQ8IY5kzsdlw9RuL8oUrAlHx1ZpcvQYfXSawOwklXY8oe7uEmCKprsOluHA4hlDUmtztySU5EGoGtN1kUX/oIl0ZJMkrHcRt0ryE+LGcCteaVryY97DpFISSDxl2OEyn02yEYKYgRehPPgZ3r38kT2v1oYy77dJrFanVSdYCUQTEIQKn/UPo2G70rbL1+MQ/gKuz81Ox9b2eVKupV8aRKuGRVSP+SmvmSEEJv8sDnupZbvY7RvUkVQd9yTw2ZzsKLXER9olaVtP4f7ovLba8wcELHXfQ5figHBPybqdQ8eEBa038xiURtPPLz1YapQFqVmgGWwjqqo8Y8/Tm+tcCAwEAAQ== + +alsoAccept.0.publicKey=MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtlvGdaBPy+eRfntUlaJO1tS4beLJ+OIj+xE0awKpnTPdt8fbLbR+PYwYLlB2sWl4tkF0V0GIZJgb3Y2ruqICNN7HLcJTlR+G9TDmF+Hu+GlCYlnHeivlQidTtC1WLXuDO2EBzQDkXzfeeP0QdtyoYqlPe1Vpt9ksTzTspBTM7VJ/10ot8OAuHThn611P6lT8QBG0MzHb/XXjGr3JqZdl4xer78lQrqnmjy3UlusgDIiyRo3/HcMyxPmWpgszLvcOHG2qQ8JVpvMEfkukB2ROSamGNy9zKAW9rqnzMyHTcrhPDlapeMUIdCSsyOT3Hh8yQT/aIR+7e48PUYrC3e2nrn8QrsebE6YC0s3wPnyH5V9u0X7UY/34B1C7nb22boOfGrMexJ3Pvy9bvdL1w80Ob/IPm7OBH8PgcJ1UzlrYnIIt6ddr5nvLvkBPgkQsiYuwEwFUQJu89NhVH/JsV35B2TycJFGdd7fMyjLGC2apMFgVvN+kautcB5+I+bcWq0/3+O7FK50IeIRzWx44xU5tyGGAhwTRMqrPlW8kg8au2Mm2MitrlkRQ3MYH8ajmUyhZKAmOMH3S6/y/fn4g4ABx7sv7vKTSwLvjCpiLNx5FxF4i0I9ZLRCr8WW3D2EqpQwJhMPL6BdhPeJ0bx2UdY2+y8L7QgerrkUKMCIej9KdqhkCAwEAAQ== diff --git a/pan-domain-auth-verification/src/test/resources/crypto-conf-rotation-example/3.rotation-complete.settings b/pan-domain-auth-verification/src/test/resources/crypto-conf-rotation-example/3.rotation-complete.settings new file mode 100644 index 0000000..efc9e72 --- /dev/null +++ b/pan-domain-auth-verification/src/test/resources/crypto-conf-rotation-example/3.rotation-complete.settings @@ -0,0 +1,5 @@ +# 3.rotation-complete.settings - Generated at 2024-09-11T16:10:11Z + +privateKey=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 + +publicKey=MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArmt3/bxQ6qdgaUHApX0M7wdwyCaa0fTeqOh0xi4ShCrNcWBvIUlKVJ2E9+FYLM7KU79szhi5odQ/cqBxpbBvTqDj7d/0LXSRkiNtAdZAt2yPxfrQ/MX36Uxm1TcfmpY9jxWcVb91s4L67uydwN3sB8NpyFayHZHkNrwdn/eT86ie9YRiRuAWu8j8P6KhNzUxm3JvWkMTRDT2YQvMpZ0kYQ+8G/Q54dXUGpu9BewRgUwU9avmeI12pKM2MiKBM7rp/i//BSH7++KCxZvcHbzjVcLaR7qMVpjDgBqy13uPSq0V/S5M3NV+Uq7nasZh/Q9o/cF2VaMMAIu6TNYAVOQDgP54lQ8IY5kzsdlw9RuL8oUrAlHx1ZpcvQYfXSawOwklXY8oe7uEmCKprsOluHA4hlDUmtztySU5EGoGtN1kUX/oIl0ZJMkrHcRt0ryE+LGcCteaVryY97DpFISSDxl2OEyn02yEYKYgRehPPgZ3r38kT2v1oYy77dJrFanVSdYCUQTEIQKn/UPo2G70rbL1+MQ/gKuz81Ox9b2eVKupV8aRKuGRVSP+SmvmSEEJv8sDnupZbvY7RvUkVQd9yTw2ZzsKLXER9olaVtP4f7ovLba8wcELHXfQ5figHBPybqdQ8eEBa038xiURtPPLz1YapQFqVmgGWwjqqo8Y8/Tm+tcCAwEAAQ== diff --git a/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/CryptoConfForRotation.scala b/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/CryptoConfForRotation.scala new file mode 100644 index 0000000..8fbdd97 --- /dev/null +++ b/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/CryptoConfForRotation.scala @@ -0,0 +1,84 @@ +package com.gu.pandomainauth + +import com.gu.pandomainauth.service.CryptoConf.SigningAndVerification +import com.gu.pandomainauth.service.{CryptoConf, KeyPair} +import org.scalatest.EitherValues + +import java.io.FileInputStream +import java.nio.file.Files +import java.security.{Key, KeyPairGenerator} +import java.time.ZoneOffset.UTC +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME +import java.time.temporal.ChronoUnit.SECONDS +import java.util.Base64 + +/** + * This class can be run from the sbt console with `pan-domain-auth-verification / Test / run`. + * + * You need to supply the _current_ Panda .settings file as the command line argument, eg: + * + * {{{ + * pan-domain-auth-verification / Test / run pan-domain-auth-verification/src/test/resources/crypto-conf-rotation-example/0.legacy. + * settings + * }}} + */ +object CryptoConfForRotation extends App with EitherValues { + + val base64Encoder = Base64.getEncoder + + args.headOption.fold( + Console.err.println("\nYou must supply the path to the current Panda .settings file, downloaded from S3.\n") + )(generateForExistingConf) + + def generateForExistingConf(pathForCurrentConf: String): Unit = { + val keyPairGenerator: KeyPairGenerator = { + val g = KeyPairGenerator.getInstance("RSA") + g.initialize(4096) + g + } + + val newTargetKeyPair = { + val kp = keyPairGenerator.generateKeyPair + KeyPair(kp.getPublic, kp.getPrivate) + } + + println(s"Loading current conf from $pathForCurrentConf") + val loader = new Settings.Loader( + _ => new FileInputStream(pathForCurrentConf), + "" + ) + val originalConf = CryptoConf.SettingsReader(loader.loadAndParseSettingsMap().value).signingAndVerificationConf.value + val rotationInProgressConf = originalConf.copy(activeKeyPair = newTargetKeyPair, alsoAccepted = Seq(originalConf.activePublicKey)) + + val tempDirWithPrefix = Files.createTempDirectory("panda-rotation") + val timeStamp = s"Generated at ${ZonedDateTime.now(UTC).truncatedTo(SECONDS).format(ISO_LOCAL_DATE_TIME)}Z" + println(s"$timeStamp - files are in:\n\n$tempDirWithPrefix\n") + for { + ((description, conf), index) <- Seq( + "upcoming" -> originalConf.copy(alsoAccepted = Seq(newTargetKeyPair.publicKey)), + "in-progress" -> rotationInProgressConf, + "complete" -> rotationInProgressConf.copy(alsoAccepted = Nil) + ).zipWithIndex + } { + val filename = s"${1 + index}.rotation-$description.settings" + println(filename) + Files.writeString(tempDirWithPrefix.resolve(filename), s"# $filename - $timeStamp\n\n${textFor(conf)}") + } + } + + def textFor(conf: SigningAndVerification): String = { + def base64For(key: Key): String = base64Encoder.encodeToString(key.getEncoded) + + val keyValues = Seq( + "privateKey" -> base64For(conf.activePrivateKey), + "publicKey" -> base64For(conf.activePublicKey) + ) ++ conf.alsoAccepted.zipWithIndex.map { + case (key, index) => s"alsoAccept.$index.publicKey" -> base64For(key) + } + assert(CryptoConf.SettingsReader(keyValues.toMap).signingAndVerificationConf.value == conf) + keyValues.map { + case (key, value) => s"$key=$value\n" + }.mkString("\n") + } +} diff --git a/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/CryptoConfTest.scala b/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/CryptoConfTest.scala index b708f05..7498b40 100644 --- a/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/CryptoConfTest.scala +++ b/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/CryptoConfTest.scala @@ -1,13 +1,42 @@ package com.gu.pandomainauth -import com.gu.pandomainauth.service.CryptoConf.SettingsReader +import com.gu.pandomainauth.service.CryptoConf.{SettingsReader, SigningAndVerification} import com.gu.pandomainauth.service.TestKeys.testPublicKey import org.scalatest.EitherValues import org.scalatest.freespec.AnyFreeSpec import org.scalatest.matchers.should.Matchers +import java.nio.charset.StandardCharsets.UTF_8 + class CryptoConfTest extends AnyFreeSpec with Matchers with EitherValues { + "loading crypto configuration" - { + "follow a safe set of transition steps" in { + val legacyConf = loadExample("0.legacy") + legacyConf.alsoAccepted shouldBe empty + + val rotationUpcomingConf = loadExample("1.rotation-upcoming") + rotationUpcomingConf.activeKeyPair should === (legacyConf.activeKeyPair) + rotationUpcomingConf.alsoAccepted should not be empty + val expectedAcceptedPublicKeys = rotationUpcomingConf.activeKeyPair.publicKey +: rotationUpcomingConf.alsoAccepted + rotationUpcomingConf.acceptedPublicKeys should === (expectedAcceptedPublicKeys) + + val rotationInProgressConf = loadExample("2.rotation-in-progress") + rotationInProgressConf.activeKeyPair should !== (legacyConf.activeKeyPair) + rotationInProgressConf.alsoAccepted shouldBe Seq(legacyConf.activeKeyPair.publicKey) + + val rotationCompleteConf = loadExample("3.rotation-complete") + rotationCompleteConf.activeKeyPair should === (rotationInProgressConf.activeKeyPair) + rotationCompleteConf.alsoAccepted shouldBe empty + } + } + + private def loadExample(name: String): SigningAndVerification = { + val settingsText = + new String(getClass.getResourceAsStream(s"/crypto-conf-rotation-example/$name.settings").readAllBytes(), UTF_8) + SettingsReader(Settings.extractSettings(settingsText).value).signingAndVerificationConf.value + } + "CryptoConf.SettingsReader" - { "returns an error if the key looks invalid" in { SettingsReader.publicKeyFor("not a valid key").left.value shouldEqual PublicKeyFormatFailure @@ -31,4 +60,5 @@ class CryptoConfTest extends AnyFreeSpec with Matchers with EitherValues { SettingsReader(Map("another key" -> "bar")).activePublicKey.left.value should be(MissingSetting("publicKey")) } } + } diff --git a/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/PanDomainTest.scala b/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/PanDomainTest.scala index 21c0058..88b180b 100644 --- a/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/PanDomainTest.scala +++ b/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/PanDomainTest.scala @@ -1,13 +1,14 @@ package com.gu.pandomainauth -import java.util.Date - import com.gu.pandomainauth.model._ import com.gu.pandomainauth.service.CookieUtils -import org.scalatest.freespec.AnyFreeSpec +import com.gu.pandomainauth.service.CryptoConf.OnlyVerification import org.scalatest.Inside +import org.scalatest.freespec.AnyFreeSpec import org.scalatest.matchers.should.Matchers +import java.util.Date + class PanDomainTest extends AnyFreeSpec with Matchers with Inside { import com.gu.pandomainauth.service.TestKeys._ @@ -19,23 +20,21 @@ class PanDomainTest extends AnyFreeSpec with Matchers with Inside { system: String = "testsuite", cacheValidation: Boolean = false, forceExpiry: Boolean = false, - ) = { - PanDomain.authStatus(cookieData, testPublicKey.key, validateUser, apiGracePeriod, system, cacheValidation, forceExpiry) - } + ) = PanDomain.authStatus(cookieData, OnlyVerification(testPublicKey.key), validateUser, apiGracePeriod, system, cacheValidation, forceExpiry) "authStatus" - { val authUser = AuthenticatedUser(User("test", "user", "test.user@example.com", None), "testsuite", Set("testsuite"), new Date().getTime + 86400, multiFactor = true) - val validCookieData = CookieUtils.generateCookieData(authUser, testPrivateKey.key) + val validCookieData = CookieUtils.generateCookieData(authUser, signingWith(testPrivateKey.key)) "returns `Authenticated` for valid cookie data that passes the validation check" in { def validateUser(au: AuthenticatedUser): Boolean = au.multiFactor && au.user.emailDomain == "example.com" - val cookieData = CookieUtils.generateCookieData(authUser, testPrivateKey.key) + val cookieData = CookieUtils.generateCookieData(authUser, signingWith(testPrivateKey.key)) authStatus(cookieData, validateUser) shouldBe a [Authenticated] } "gives back the provided auth user if successful" in { - val cookieData = CookieUtils.generateCookieData(authUser, testPrivateKey.key) + val cookieData = CookieUtils.generateCookieData(authUser, signingWith(testPrivateKey.key)) authStatus(cookieData) should equal(Authenticated(authUser)) } @@ -45,40 +44,40 @@ class PanDomainTest extends AnyFreeSpec with Matchers with Inside { } "returns `InvalidCookie` if the cookie fails its signature check" in { - val incorrectCookieData = CookieUtils.generateCookieData(authUser, testINCORRECTPrivateKey.key) + val incorrectCookieData = CookieUtils.generateCookieData(authUser, signingWith(testINCORRECTPrivateKey.key)) authStatus(incorrectCookieData) shouldBe a [InvalidCookie] } "returns `Expired` if the time is after the cookie's expiry" in { val expiredAuthUser = authUser.copy(expires = new Date().getTime - 86400) - val cookieData = CookieUtils.generateCookieData(expiredAuthUser, testPrivateKey.key) + val cookieData = CookieUtils.generateCookieData(expiredAuthUser, signingWith(testPrivateKey.key)) authStatus(cookieData) shouldBe a [Expired] } "returns `Expired` if the cookie has expired and is outside the grace period" in { val expiredAuthUser = authUser.copy(expires = new Date().getTime - 86400) - val cookieData = CookieUtils.generateCookieData(expiredAuthUser, testPrivateKey.key) + val cookieData = CookieUtils.generateCookieData(expiredAuthUser, signingWith(testPrivateKey.key)) authStatus(cookieData) shouldBe a [Expired] } "returns `Expired` if cookie has not expired, but forceExpiry is set" in { - val validCookieData = CookieUtils.generateCookieData(authUser, testPrivateKey.key) + val validCookieData = CookieUtils.generateCookieData(authUser, signingWith(testPrivateKey.key)) authStatus(validCookieData, forceExpiry = true) shouldBe a [Expired] } "returns `GracePeriod` if the cookie has expired but is within the grace period" in { val expiredAuthUser = authUser.copy(expires = new Date().getTime - 3000) - val cookieData = CookieUtils.generateCookieData(expiredAuthUser, testPrivateKey.key) + val cookieData = CookieUtils.generateCookieData(expiredAuthUser, signingWith(testPrivateKey.key)) authStatus(cookieData, apiGracePeriod = 3600) shouldBe a [GracePeriod] } "returns `NotAuthorized` if the cookie does not pass the verification check" in { def validateUser(au: AuthenticatedUser): Boolean = au.multiFactor && au.user.emailDomain == "example.com" - val cookieData = CookieUtils.generateCookieData(authUser, testPrivateKey.key) + val cookieData = CookieUtils.generateCookieData(authUser, signingWith(testPrivateKey.key)) authStatus(cookieData, _ => false) shouldBe a [NotAuthorized] } diff --git a/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/service/CookieUtilsTest.scala b/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/service/CookieUtilsTest.scala index 346da2e..10d8da2 100644 --- a/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/service/CookieUtilsTest.scala +++ b/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/service/CookieUtilsTest.scala @@ -3,10 +3,12 @@ package com.gu.pandomainauth.service import com.gu.pandomainauth.model.{AuthenticatedUser, User} import com.gu.pandomainauth.service.CookieUtils.CookieIntegrityFailure.{MalformedCookieText, SignatureNotValid} import com.gu.pandomainauth.service.CookieUtils.{deserializeAuthenticatedUser, parseCookieData, serializeAuthenticatedUser} +import com.gu.pandomainauth.service.CryptoConf.{OnlyVerification, Signing} import org.scalatest.freespec.AnyFreeSpec import org.scalatest.matchers.should.Matchers import org.scalatest.{EitherValues, OptionValues} +import java.security.PrivateKey import java.util.Date @@ -17,28 +19,30 @@ class CookieUtilsTest extends AnyFreeSpec with Matchers with EitherValues with O "generateCookieData" - { "generates a base64-encoded 'data.signature' cookie value" in { - CookieUtils.generateCookieData(authUser, testPrivateKey.key) should fullyMatch regex "[\\w+/]+=*\\.[\\w+/]+=*".r + CookieUtils.generateCookieData(authUser, signingWith(testPrivateKey.key)) should fullyMatch regex "[\\w+/]+=*\\.[\\w+/]+=*".r } } "parseCookieData" - { + val cryptoConf = OnlyVerification(testPublicKey.key) + "can extract an authenticatedUser from real cookie data" in { - val cookieData = CookieUtils.generateCookieData(authUser, testPrivateKey.key) + val cookieData = CookieUtils.generateCookieData(authUser, signingWith(testPrivateKey.key)) - parseCookieData(cookieData, testPublicKey.key).value should equal(authUser) + parseCookieData(cookieData, cryptoConf).value should equal(authUser) } "fails to extract invalid data with a SignatureNotValid" in { - parseCookieData("data.invalidSignature", testPublicKey.key).left.value shouldBe SignatureNotValid + parseCookieData("data.invalidSignature", cryptoConf).left.value shouldBe SignatureNotValid } "fails to extract incorrectly signed data with a CookieSignatureInvalidException" in { - val cookieData = CookieUtils.generateCookieData(authUser, testINCORRECTPrivateKey.key) - parseCookieData(cookieData, testPublicKey.key).left.value should equal(SignatureNotValid) + val cookieData = CookieUtils.generateCookieData(authUser, signingWith(testINCORRECTPrivateKey.key)) + parseCookieData(cookieData, cryptoConf).left.value should equal(SignatureNotValid) } "fails to extract completely incorrect cookie data with a CookieParseException" in { - parseCookieData("Completely incorrect cookie data", testPublicKey.key).left.value shouldBe MalformedCookieText + parseCookieData("Completely incorrect cookie data", cryptoConf).left.value shouldBe MalformedCookieText } } diff --git a/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/service/TestKeys.scala b/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/service/TestKeys.scala index ac31866..5d23656 100644 --- a/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/service/TestKeys.scala +++ b/pan-domain-auth-verification/src/test/scala/com/gu/pandomainauth/service/TestKeys.scala @@ -1,9 +1,10 @@ package com.gu.pandomainauth.service -import com.gu.pandomainauth.SettingsFailure.SettingsResult +import com.gu.pandomainauth.Settings.SettingsResult import com.gu.pandomainauth.service.CryptoConf.SettingsReader.{privateKeyFor, publicKeyFor} +import com.gu.pandomainauth.service.CryptoConf.Signing -import java.security.Key +import java.security.{Key, PrivateKey} object TestKeys { @@ -11,6 +12,10 @@ object TestKeys { def example[K <: Key](f: String => SettingsResult[K])(base64Encoded: String): Example[K] = Example(f(base64Encoded).toOption.get, base64Encoded) + def signingWith(privateKey: PrivateKey): Signing = new Signing { + override val activePrivateKey: PrivateKey = privateKey + } + /** * A test public/private key-pair */ diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 85565c5..d009b64 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -49,5 +49,5 @@ object Dependencies { // provide compatibility between scala 2.12 and 2.13 // see https://github.com/scala/scala-collection-compat/issues/208 - val scalaCollectionCompatDependencies = Seq("org.scala-lang.modules" %% "scala-collection-compat" % "2.1.6") + val scalaCollectionCompatDependencies = Seq("org.scala-lang.modules" %% "scala-collection-compat" % "2.12.0") }