Skip to content

Commit

Permalink
Rework transformWithoutMerge
Browse files Browse the repository at this point in the history
  • Loading branch information
colin-lamed committed Sep 16, 2024
1 parent 9acf98e commit 01df72e
Show file tree
Hide file tree
Showing 2 changed files with 73 additions and 59 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ object AdCryptoUtils {
*
* @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.
* @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,
Expand All @@ -49,13 +51,10 @@ object AdCryptoUtils {
case JsError(errors) => sys.error(s"Failed to decrypt value: $errors")
}
encryptedFieldPaths.foldLeft(jsValue){ (js, encryptedFieldPath) =>
if (encryptedFieldPath(jsValue).nonEmpty)
transformWithoutMerge(js, encryptedFieldPath, transform) match {
case JsSuccess(r, _) => r
case JsError(errors) => sys.error(s"Could not decrypt at $encryptedFieldPath: $errors")
}
else
js
js.transform(updateWithoutMerge(encryptedFieldPath, transform)) match {
case JsSuccess(r, _) => r
case JsError(errors) => sys.error(s"Could not decrypt at $encryptedFieldPath: $errors")
}
}
}

Expand All @@ -64,28 +63,34 @@ object AdCryptoUtils {
def transform(js: JsValue): JsValue =
CryptoFormats.encryptedValueFormat.writes(crypto.encrypt(js.toString, ad))
encryptedFieldPaths.foldLeft(jsValue){ (js, encryptedFieldPath) =>
if (encryptedFieldPath(jsValue).nonEmpty)
transformWithoutMerge(js, encryptedFieldPath, transform) match {
case JsSuccess(r, _) => r
case JsError(errors) => sys.error(s"Could not encrypt at $encryptedFieldPath: $errors")
}
else
js
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(jsValue) match {
case List(associatedData) => associatedData.as[String] // Note only supports associatedDataPath which points to a String
case Nil => sys.error(s"No associatedData was found with $associatedDataPath")
case _ => sys.error(s"Multiple associatedData was found with $associatedDataPath")
}
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 transformWithoutMerge(js: JsValue, path: JsPath, transformFn: JsValue => JsValue): JsResult[JsValue] =
js.transform(
path.json.update(implicitly[Reads[JsValue]].map(_ => transformFn(path(js).head)))
// Calling js.transform(path.json.update ...) actually merges the result of transform
// So we have to ensure the original (unencrypted) JsObject is removed
.composeWith(path.json.update(implicitly[Reads[JsValue]].map(_ => JsNull)))
)
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
Expand Up @@ -37,54 +37,62 @@ class AdCryptoUtilsSpec
( (__ \ "aField").format[String]
~ (__ \ "bField").format[String]
)(TestSubEntity.apply, o => (o.aField, o.bField))
( (__ \ "name" ).format[String]
~ (__ \ "aString" ).format[String]
~ (__ \ "aBoolean").format[Boolean]
~ (__ \ "aNumber" ).format[BigDecimal]
~ (__ \ "aObject" ).format[TestSubEntity]
)(TestEntity.apply, o => (o.name, o.aString, o.aBoolean, o.aNumber, o.aObject))
( (__ \ "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 = __ \ "name",
associatedDataPath = __ \ "asd" \ "name",
encryptedFieldPaths = Seq(
__ \ "aString",
__ \ "aBoolean",
__ \ "aNumber",
__ \ "aObject"
__ \ "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"
)
)
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 \ "name").as[String] shouldBe "name"
(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 "bField"
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 \ "aString" ).validate[EncryptedValue] shouldBe a[JsSuccess[_]]
(cryptoJson \ "aBoolean").validate[EncryptedValue] shouldBe a[JsSuccess[_]]
(cryptoJson \ "aNumber" ).validate[EncryptedValue] shouldBe a[JsSuccess[_]]
(cryptoJson \ "aObject" ).validate[EncryptedValue] shouldBe a[JsSuccess[_]]
(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[_]]
}
}
}
Expand All @@ -100,11 +108,12 @@ object AdCryptoUtilsSpec {
}

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

case class TestSubEntity(
Expand Down

0 comments on commit 01df72e

Please sign in to comment.