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

Add AdCryptoUtils #26

Merged
merged 3 commits into from
Sep 16, 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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,14 @@ val decrypter = JsonEncryption.sensitiveDecrypter[T, SensitiveT[T]](SensitiveT.a
val optValue: Option[T] = decrypter.reads(encryptedValue).asOpt.map(_.decryptedValue)
```

#### AdCryptoUtils

Contains utilities for working with associated data.

`AdCryptoUtils.encryptWith` will wrap a given json `Format` with AD encryption. It requires a pointer (`JSPath`) to the associated data field and takes a list of pointers to the fields to encrypt.

See [spec](crypto-json/src/test/scala/uk/gov/hmrc/crypto/json/AdCryptoUtilsSpec.scala) for an example.

## Changes

### Version 8.0.0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright 2024 HM Revenue & Customs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package uk.gov.hmrc.crypto.json

import play.api.libs.json._
import uk.gov.hmrc.crypto.{AdDecrypter, AdEncrypter}

object AdCryptoUtils {

/** Will adapt a Format (written plainly without encryption) to one applying encryption with associated data.
* It requires a JsPath to point to the associated data field, and JsPaths to point at the fields to be encrypted with it.
*
* @param associatedDataPath field to be used as the associated data. It must resolve to a single `String` field, otherwise use of the Format will result in an error.
*
* @param encryptedFieldPaths fields to be encrypted.
* The fields can be optional - if the field does not exist, it simply won't be encrypted. This does mean that client's tests must ensure that encryption occurs as expected to detect misspellings etc.
* The field path cannot point to multiple values.
*/
def encryptWith[A](
associatedDataPath : JsPath,
encryptedFieldPaths: Seq[JsPath]
)(
f: Format[A]
)(implicit
crypto: AdEncrypter with AdDecrypter
): Format[A] =
Format(
f.preprocess(js => decryptTransform(associatedDataPath, encryptedFieldPaths)(js)),
f.transform(encryptTransform(associatedDataPath, encryptedFieldPaths))
)

private def decryptTransform(associatedDataPath: JsPath, encryptedFieldPaths: Seq[JsPath])(jsValue: JsValue)(implicit crypto: AdEncrypter with AdDecrypter): JsValue = {
lazy val ad = associatedData(associatedDataPath, jsValue)
def transform(js: JsValue): JsValue =
CryptoFormats.encryptedValueFormat.reads(js) match {
case JsSuccess(ev, _) => Json.parse(crypto.decrypt(ev, ad))
case JsError(errors) => sys.error(s"Failed to decrypt value: $errors")
}
encryptedFieldPaths.foldLeft(jsValue){ (js, encryptedFieldPath) =>
js.transform(updateWithoutMerge(encryptedFieldPath, transform)) match {
case JsSuccess(r, _) => r
case JsError(errors) => sys.error(s"Could not decrypt at $encryptedFieldPath: $errors")
}
}
}

private def encryptTransform(associatedDataPath: JsPath, encryptedFieldPaths: Seq[JsPath])(jsValue: JsValue)(implicit crypto: AdEncrypter with AdDecrypter): JsValue = {
lazy val ad = associatedData(associatedDataPath, jsValue)
def transform(js: JsValue): JsValue =
CryptoFormats.encryptedValueFormat.writes(crypto.encrypt(js.toString, ad))
encryptedFieldPaths.foldLeft(jsValue){ (js, encryptedFieldPath) =>
js.transform(updateWithoutMerge(encryptedFieldPath, transform)) match {
case JsSuccess(r, _) => r
case JsError(errors) => sys.error(s"Could not encrypt at $encryptedFieldPath: $errors")
}
}
}

private def associatedData(associatedDataPath: JsPath, jsValue: JsValue) =
associatedDataPath.asSingleJsResult(jsValue)
// Note only supports associatedDataPath which points to a String
.flatMap(_.validate[String])
.fold(es => sys.error(s"Failed to look up associated data: $es"), identity)

private def updateWithoutMerge(path: JsPath, transformFn: JsValue => JsValue): Reads[JsObject] =
// not using `path.json.update(o => JsSuccess(transformFn(o)))` since this does a deep merge - keeping the unencrypted values around for JsObject
Reads[JsObject] {
case o: JsObject =>
path(o) match {
case Nil => JsSuccess(o)
case List(one: JsObject) => JsSuccess(
o
.deepMerge(JsPath.createObj(path -> JsNull)) // ensure we don't merge with existing clear-text data
.deepMerge(JsPath.createObj(path -> transformFn(one)))
)
case List(one) => JsSuccess(o.deepMerge(JsPath.createObj(path -> transformFn(one))))
case multiple => JsError(Seq(path -> Seq(JsonValidationError("error.path.result.multiple"))))
}
case _ =>
JsError(JsPath, JsonValidationError("error.expected.jsobject"))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright 2024 HM Revenue & Customs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package uk.gov.hmrc.crypto.json

import org.scalatest.OptionValues
import org.scalatest.wordspec.AnyWordSpecLike
import org.scalatest.matchers.should.Matchers
import play.api.libs.functional.syntax._
import play.api.libs.json.{Format, Json, JsSuccess, Reads, __}
import uk.gov.hmrc.crypto.{AdDecrypter, AdEncrypter, EncryptedValue}

class AdCryptoUtilsSpec
extends AnyWordSpecLike
with Matchers
with OptionValues {
import AdCryptoUtilsSpec._

"sensitiveEncrypterDecrypter" should {
"encrypt/decrypt primitives" in {

val testEntityFormat: Format[TestEntity] = {
implicit val tsef: Format[TestSubEntity] =
( (__ \ "aField").format[String]
~ (__ \ "bField").format[String]
)(TestSubEntity.apply, o => (o.aField, o.bField))
( (__ \ "asd" \ "name" ).format[String]
~ (__ \ "asd" \ "aString" ).format[String]
~ (__ \ "asd" \ "aBoolean").format[Boolean]
~ (__ \ "asd" \ "aNumber" ).format[BigDecimal]
~ (__ \ "asd" \ "aObject" ).format[TestSubEntity]
~ (__ \ "asd" \ "anArray" ).format[List[String]]
)(TestEntity.apply, o => (o.name, o.aString, o.aBoolean, o.aNumber, o.aObject, o.anArray))
}

val cryptoFormat: Format[TestEntity] =
AdCryptoUtils.encryptWith[TestEntity](
associatedDataPath = __ \ "asd" \ "name",
encryptedFieldPaths = Seq(
__ \ "asd" \ "aString",
__ \ "asd" \ "aBoolean",
__ \ "asd" \ "aNumber",
__ \ "asd" \ "aObject",
__ \ "asd" \ "anArray",
__ \ "nonExisting"
)
)(testEntityFormat)

val testEntity =
TestEntity(
name = "name",
aString = "string",
aBoolean = true,
aNumber = BigDecimal(1.0),
aObject = TestSubEntity(
aField = "aField",
bField = "bField"
),
anArray = List("array0", "array1")
)

val cryptoJson = cryptoFormat.writes(testEntity)

// be able to read json back
Json.fromJson[TestEntity](cryptoJson)(cryptoFormat).asOpt.value shouldBe testEntity

// not contain raw values
(cryptoJson \ "asd" \ "name").as[String] shouldBe "name"
cryptoJson.toString should not include "string"
cryptoJson.toString should not include "true"
cryptoJson.toString should not include "aField"
cryptoJson.toString should not include "bField0"
cryptoJson.toString should not include "array0"
cryptoJson.toString should not include "array1"

// be encrypted
implicit val evr: Reads[EncryptedValue] = CryptoFormats.encryptedValueFormat
(cryptoJson \ "asd" \ "aString" ).validate[EncryptedValue] shouldBe a[JsSuccess[_]]
(cryptoJson \ "asd" \ "aBoolean").validate[EncryptedValue] shouldBe a[JsSuccess[_]]
(cryptoJson \ "asd" \ "aNumber" ).validate[EncryptedValue] shouldBe a[JsSuccess[_]]
(cryptoJson \ "asd" \ "aObject" ).validate[EncryptedValue] shouldBe a[JsSuccess[_]]
(cryptoJson \ "asd" \ "anArray" ).validate[EncryptedValue] shouldBe a[JsSuccess[_]]
}
}
}

object AdCryptoUtilsSpec {
implicit val crypto: AdEncrypter with AdDecrypter = {
val aesKey = {
val aesKey = new Array[Byte](32)
new java.security.SecureRandom().nextBytes(aesKey)
java.util.Base64.getEncoder.encodeToString(aesKey)
}
uk.gov.hmrc.crypto.SymmetricCryptoFactory.aesGcmAdCrypto(aesKey)
}

case class TestEntity(
name : String,
aString : String,
aBoolean : Boolean,
aNumber : BigDecimal,
aObject : TestSubEntity,
anArray : List[String]
)

case class TestSubEntity(
aField: String,
bField: String
)
}
6 changes: 3 additions & 3 deletions project/LibDependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,18 @@ object LibDependencies {
"org.scalatest" %% "scalatest" % "3.2.17" % Test,
"com.vladsch.flexmark" % "flexmark-all" % "0.64.8" % Test,
"org.scalatestplus" %% "scalacheck-1-17" % "3.2.17.0" % Test,
"org.scalatestplus" %% "mockito-3-4" % "3.2.10.0" % Test
"org.scalatestplus" %% "mockito-4-11" % "3.2.17.0" % Test
)

val cryptoJsonPlay28Compile = Seq(
"com.typesafe.play" %% "play-json" % "2.8.2" // version provided by Play 2.8
)

val cryptoJsonPlay29Compile = Seq(
"com.typesafe.play" %% "play-json" % "2.10.5" // version provided by Play 2.9
"com.typesafe.play" %% "play-json" % "2.10.6" // version provided by Play 2.9
)

val cryptoJsonPlay30Compile = Seq(
"org.playframework" %% "play-json" % "3.0.3" // version provided by Play 3.0
"org.playframework" %% "play-json" % "3.0.4" // version provided by Play 3.0
)
}
2 changes: 1 addition & 1 deletion project/plugins.sbt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
resolvers += MavenRepository("HMRC-open-artefacts-maven2", "https://open.artefacts.tax.service.gov.uk/maven2")
resolvers += Resolver.url("HMRC-open-artefacts-ivy2", url("https://open.artefacts.tax.service.gov.uk/ivy2"))(Resolver.ivyStylePatterns)

addSbtPlugin("uk.gov.hmrc" % "sbt-auto-build" % "3.21.0")
addSbtPlugin("uk.gov.hmrc" % "sbt-auto-build" % "3.22.0")