Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Re-merge of PR #150: Support accepting multiple public keys #156

Merged
merged 7 commits into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down
21 changes: 0 additions & 21 deletions generateKeyPair.sh

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions pan-domain-auth-example/app/VerifyExample.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"
Expand All @@ -42,7 +43,7 @@ object VerifyExample {
val cacheValidation = false

// To verify, call the authStatus method with the encoded cookie data
val status = PanDomain.authStatus("<<cookie data>>>", publicKey, validateUser, apiGracePeriod, system, cacheValidation, forceExpiry = false)
val status = PanDomain.authStatus("<<cookie data>>>", verification, validateUser, apiGracePeriod, system, cacheValidation, forceExpiry = false)

status match {
case Authenticated(_) | GracePeriod(_) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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] = {
Expand All @@ -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)
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}

/**
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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._
Expand Down Expand Up @@ -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
*/
Expand All @@ -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
Expand All @@ -86,15 +94,21 @@ 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)

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,21 @@ 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)
rsa.sign()
}

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)
Expand Down
Loading
Loading