Skip to content

Commit

Permalink
Merge pull request #156 from guardian/pr-150-remerge
Browse files Browse the repository at this point in the history
Re-merge of PR #150: Support accepting multiple public keys
  • Loading branch information
rtyley authored Sep 18, 2024
2 parents 602d1a3 + 6d6083f commit 7e4103f
Show file tree
Hide file tree
Showing 24 changed files with 329 additions and 114 deletions.
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

0 comments on commit 7e4103f

Please sign in to comment.