Skip to content

Commit

Permalink
Support accepting multiple public keys
Browse files Browse the repository at this point in the history
  • Loading branch information
rtyley committed Aug 7, 2024
1 parent 8592c75 commit 086257c
Show file tree
Hide file tree
Showing 18 changed files with 167 additions and 54 deletions.
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
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ class PanDomainAuthSettingsRefresher(
private val settingsRefresher = new Settings.Refresher[PanDomainAuthSettings](
new Settings.Loader(s3BucketLoader, settingsFileKey),
PanDomainAuthSettings.extractFrom,
(o, n) => {
for (change <- CryptoConf.Change.compare(o.cryptoConf, n.cryptoConf)) {
val message = s"PanDomainAuthSettings have changed for $domain: ${change.summary}"
if (change.isBreakingChange) logger.warn(message) else logger.info(message)
}
},
scheduler
)
settingsRefresher.start(1)
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.service.CryptoConf
import com.gu.pandomainauth.service.CryptoConf.SigningAndVerification

case class PanDomainAuthSettings(
signingKeyPair: KeyPair,
cryptoConf: 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).extractFullConf
} 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 @@ -20,7 +20,8 @@ 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
// `OnlyVerification` will return None if a value has not been successfully obtained
val OnlyVerification = publicSettings.verification

// The name of this particular application
val system = "test"
Expand All @@ -41,7 +42,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>>>", OnlyVerification, 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.cryptoConf).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.cryptoConf.activeKeyPair.privateKey),
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.cryptoConf, 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, cryptoConf: Verification, validateUser: AuthenticatedUser => Boolean,
apiGracePeriod: Long, system: String, cacheValidation: Boolean, forceExpiry: Boolean): AuthenticationStatus = {
CookieUtils.parseCookieData(cookieData, publicKey).fold(InvalidCookie, { authedUser =>
CookieUtils.parseCookieData(cookieData, cryptoConf).fold(InvalidCookie, { authedUser =>
checkStatus(authedUser, validateUser, apiGracePeriod, system, cacheValidation, forceExpiry)
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.gu.pandomainauth

import com.gu.pandomainauth.SettingsFailure.SettingsResult
import com.gu.pandomainauth.service.CryptoConf
import com.gu.pandomainauth.service.CryptoConf.Verification

import java.security.PublicKey
import java.util.concurrent.{Executors, ScheduledExecutorService}
Expand All @@ -16,15 +17,24 @@ import scala.concurrent.duration._
class PublicSettings(loader: Settings.Loader,
scheduler: ScheduledExecutorService = Executors.newScheduledThreadPool(1)) {

private val settingsRefresher = new Settings.Refresher[PublicKey](
private val settingsRefresher = new Settings.Refresher[Verification](
loader,
CryptoConf.SettingsReader(_).activePublicKey,
CryptoConf.SettingsReader(_).extractVerificationConf,
(o, n) => {
// for (change <- CryptoConf.Change.compare(o, n)) {
// val message = s"PanDomainAuthSettings have changed for $domain: ${change.summary}"
// if (change.isBreakingChange) logger.warn(message) else logger.info(message)
// }
},
scheduler
)

def start(interval: FiniteDuration = 60.seconds): Unit = settingsRefresher.start(interval.toMinutes.toInt)

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 @@ -34,11 +44,6 @@ class PublicSettings(loader: Settings.Loader,
object PublicSettings {
import Settings._

/**
* 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(_).extractVerificationConf)
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ object Settings {
class Refresher[A](
loader: Settings.Loader,
settingsParser: Map[String, String] => SettingsResult[A],
changeMonitor: (A, A) => Unit,
scheduler: ScheduledExecutorService = Executors.newScheduledThreadPool(1)
) {
// This is deliberately designed to throw an exception during construction if we cannot immediately read the settings
Expand All @@ -101,6 +102,7 @@ object Settings {
case Right(newSettings) =>
// logger.debug(s"Updated pan-domain settings for $domain")
val oldSettings = store.getAndSet(newSettings)
changeMonitor(oldSettings, newSettings)
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,9 @@ package com.gu.pandomainauth.service

import com.gu.pandomainauth.model.{AuthenticatedUser, User}
import com.gu.pandomainauth.service.CookieUtils.CookieIntegrityFailure.{MalformedCookieText, MissingUserData, SignatureNotValid}
import com.gu.pandomainauth.service.CryptoConf.Verification

import java.security.{PrivateKey, PublicKey}
import java.security.PrivateKey

object CookieUtils {
sealed trait CookieIntegrityFailure
Expand Down Expand Up @@ -54,9 +55,9 @@ object CookieUtils {

// We would quite like to know, if a user is using an old (but accepted) key, *who* that user is- or to put it another
// way, give me the authenticated user, and tell me which key they're using
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(MissingUserData)
} yield authUser
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
package com.gu.pandomainauth.service

import org.apache.commons.codec.binary.Base64._
import org.bouncycastle.jce.provider.BouncyCastleProvider

import java.security.spec.{PKCS8EncodedKeySpec, X509EncodedKeySpec}
import java.security._


Expand All @@ -16,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
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,52 @@ import java.security.spec.{InvalidKeySpecException, PKCS8EncodedKeySpec, X509Enc
import java.security.{PrivateKey, PublicKey}
import scala.util.Try


case class KeyPair(publicKey: PublicKey, privateKey: PrivateKey)

object CryptoConf {
trait Signing {
val activePrivateKey: PrivateKey
}

trait Verification {
val activePublicKey: PublicKey
val alsoAccepted: Seq[PublicKey]

val acceptedPublicKeys: Stream[PublicKey] = Stream(activePublicKey) ++ alsoAccepted

def decode[A](f: PublicKey => Option[A]): Option[A] = acceptedPublicKeys.flatMap(f(_)).headOption
}

case class SigningAndVerification(activeKeyPair: KeyPair, alsoAccepted: Seq[PublicKey]) extends Verification with Signing {
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 extractFullConf: SettingsResult[SigningAndVerification] = makeConfWith(activeKeyPair)(SigningAndVerification)
def extractVerificationConf: SettingsResult[Verification] = makeConfWith(activePublicKey)(OnlyVerification)

val activePublicKey: SettingsResult[PublicKey] = setting("publicKey").flatMap(publicKeyFor)

val alsoAcceptedPublicKeys: SettingsResult[Seq[PublicKey]] = settingMap.collect {
case (k, v) if k.startsWith("alsoAccept.") && k.endsWith(".publicKey") => publicKeyFor(v)
}.toSeq.sequence

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 {
Expand All @@ -42,4 +76,33 @@ object CryptoConf {

def privateKeyFor(base64EncodedKey: String): SettingsResult[PrivateKey] = keyFor(privateKeyFor, base64EncodedKey)
}

object Change {
def compare(oldConf: SigningAndVerification, newConf: SigningAndVerification): Option[CryptoConf.Change] =
if (newConf == oldConf) None else Some(Change(
activeKey = if (newConf.activeKeyPair == oldConf.activeKeyPair) None else Some(ActiveKey(
toleratingOldKey = newConf.alsoAccepted.contains(oldConf.activeKeyPair.publicKey),
newKeyAlreadyAccepted = oldConf.alsoAccepted.contains(newConf.activeKeyPair.publicKey)
)),
SeqDiff.compare(oldConf.alsoAccepted, newConf.alsoAccepted)
))

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"}"
}
}

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))
}
}
Original file line number Diff line number Diff line change
@@ -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==
Loading

0 comments on commit 086257c

Please sign in to comment.