Add limited JCS implementation [DPP-1176] (#14626)

* Add limited JCS implementation

changelog_begin
changelog_end
This commit is contained in:
Simon Maxen 2022-08-08 18:09:40 +01:00 committed by GitHub
parent e6f3ec034e
commit e1f831a248
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 227 additions and 0 deletions

View File

@ -0,0 +1,75 @@
# JCS
This document describes the JCS implementation used for ledger metering
## Background
As part of the ledger metering tamperproofing design a decision was made to use the JSON
Canonicalization Scheme (JCS) to render a byte array that represented the contents of the
metering report. The MAC of this byte array is then appended to the metering report JSON
as a tamperproofing measure. The JCS spec is published
as [RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785).
## Java Implementation
The java reference implementation provided in the RFC is
by [Samuel Erdtman](https://github.com/erdtman/java-json-canonicalization). Concerns
about this implementation are that it:
* Has not been released since 2020
* Has only has one committer
* Has vulnerability [CVE-2020-15250](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-15250)
* Does its own JSON parsing
* Has relatively few lines of testing given the size of the code base
## Javascript Implementation
Erdtman also has a [javascript implementation of the algorithm in 32 lines of javascript](https://github.com/erdtman/canonicalize/blob/master/lib/canonicalize.js).
The reason this implementation is so small is because:
* Javascript has native support for JSON
* The JCS spec uses the [javascript standard for number formatting](https://262.ecma-international.org/10.0/#sec-tostring-applied-to-the-number-type)
* The JCS uses JSON.stringify to serialize strings
## DA Implementation
Our starting point is similar to the situation in the javascript implementation in that:
* We have a parsed JSON object (or standard libraries we use to do this)
* We have JSON library functions that provide methods to stringify JSON objects.
For this reason the `Jcs.scala` class implements an algorithm similar to that in javascript.
## Number Limitation
When testing this implementation we discovered that the number formatting did not follow
the javascript standard. This is in part because scala implementations usually support
numeric values to be larger than a `IEEE-754` 64 bit `Double` (e.g. `BigDecimal`).
### Workaround
By adding a limitation that we only support whole numbers smaller than 2^52 in absolute size
and formatting them without scientific notation (using `BigInt`) we avoid these problems.
This limitation is not restrictive for the ledger metering JSON whose only numeric field
is the event count.
### Approximation
The following approximates that javascript spec double formatting rules but was discarded
in favour of explicitly limiting the implementation.
```scala
def serialize(bd: BigDecimal): String = {
if (bd.isWhole && bd.toBigInt.toString().length < 22) {
bd.toBigInt.toString()
} else {
val s = bd.toString()
if (s.contains('E')) {
s.replaceFirst("(\\.0|)E", "e")
} else {
s.replaceFirst("0+$", "")
}
}
}
```

View File

@ -0,0 +1,52 @@
// Copyright (c) 2022 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.daml.platform.apiserver.meteringreport
import spray.json.{CompactPrinter, JsArray, JsNumber, JsObject, JsString, JsValue}
/** A limited implementation of JCS https://datatracker.ietf.org/doc/html/rfc8785
*
* The limitations of this implementation are that:
* - only whole numbers are supported
* - only numbers with an absolute value less than pow(2,52) are supported
*
* The primary reason for the above is that this is suitable for our needs and avoids
* getting into difficult areas regarding the JCS spec
*
* For details see JCS.md
*/
object Jcs {
import scalaz._
import scalaz.syntax.traverse._
import std.either._
import std.vector._
val MaximumSupportedAbsSize: BigDecimal = BigDecimal(2).pow(52)
type FailOr[T] = Either[String, T]
def serialize(json: JsValue): FailOr[String] = json match {
case JsNumber(value) if !value.isWhole =>
Left(s"Only whole numbers are supported, not $value")
case JsNumber(value) if value.abs >= MaximumSupportedAbsSize =>
Left(
s"Only numbers with an abs size less than $MaximumSupportedAbsSize are supported, not $value"
)
case JsNumber(value) =>
Right(value.toBigInt.toString())
case JsArray(elements) =>
elements.traverse(serialize).map(_.mkString("[", ",", "]"))
case JsObject(fields) =>
fields.toVector.sortBy(_._1).traverse(serializePair).map(_.mkString("{", ",", "}"))
case leaf => Right(compact(leaf))
}
private def compact(v: JsValue): String = CompactPrinter(v)
private def serializePair(kv: (String, JsValue)): FailOr[String] = {
val (k, v) = kv
serialize(v).map(vz => s"${compact(JsString(k))}:$vz")
}
}

View File

@ -0,0 +1,100 @@
// Copyright (c) 2022 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.daml.platform.apiserver.meteringreport
import com.daml.lf.data.Ref
import com.daml.lf.data.Time.Timestamp
import com.daml.platform.apiserver.meteringreport.Jcs.{MaximumSupportedAbsSize, serialize}
import com.daml.platform.apiserver.meteringreport.MeteringReport.{
ApplicationReport,
ParticipantReport,
Request,
}
import org.scalatest.Assertion
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
import spray.json.{JsArray, JsBoolean, JsNull, JsNumber, JsObject, JsString, JsValue, enrichAny}
class JcsSpec extends AnyWordSpec with Matchers {
private val uno: JsValue = JsNumber(1)
private val badNumber: JsValue = JsNumber(1.2)
def assert(value: JsValue, expected: String): Assertion = {
val actual = Jcs.serialize(value)
actual shouldBe Right(expected)
}
"Jcs" should {
"serialize JsString" in {
assert(JsString("a\tb\nc"), "\"a\\tb\\nc\"")
}
"serialize JsBoolean" in {
assert(JsBoolean(true), "true")
assert(JsBoolean(false), "false")
}
"serialize JsNull" in {
assert(JsNull, "null")
}
"serialize JsNumber(0)" in {
assert(JsNumber(0), "0")
}
"serialize JsNumber < 2^52" in {
assert(JsNumber(MaximumSupportedAbsSize - 1), "4503599627370495")
assert(JsNumber(-MaximumSupportedAbsSize + 1), "-4503599627370495")
}
"not serialize JsNumber >= 2^52" in {
serialize(JsNumber(MaximumSupportedAbsSize)).isLeft shouldBe true
serialize(JsNumber(-MaximumSupportedAbsSize)).isLeft shouldBe true
}
"not serialize Decimals" in {
serialize(badNumber).isLeft shouldBe true
}
"serialize JsNumber without scientific notation" in {
assert(JsNumber(1000000000000000d), "1000000000000000")
assert(JsNumber(-1000000000000000d), "-1000000000000000")
}
"serialize JsArray" in {
assert(JsArray(Vector(true, false).map(b => JsBoolean(b))), "[true,false]")
}
"not serialize a JsArray containing an invalid number" in {
serialize(JsArray(Vector(badNumber))).isLeft shouldBe true
}
"serialize JsObject in key order" in {
assert(JsObject(Map("b" -> uno, "c" -> uno, "a" -> uno)), "{\"a\":1,\"b\":1,\"c\":1}")
}
"not serialize JsObject containing invalid number" in {
serialize(JsObject(Map("n" -> badNumber))).isLeft shouldBe true
}
"serialize report" in {
val application = Ref.ApplicationId.assertFromString("a0")
val from = Timestamp.assertFromString("2022-01-01T00:00:00Z")
val to = Timestamp.assertFromString("2022-01-01T00:00:00Z")
val report = ParticipantReport(
participant = Ref.ParticipantId.assertFromString("p0"),
request = Request(from, Some(to), Some(application)),
`final` = false,
applications = Seq(ApplicationReport(application, 272)),
)
val reportJson: JsValue = report.toJson
assert(
reportJson,
expected = "{" +
"\"applications\":[{" +
"\"application\":\"a0\"," +
"\"events\":272" +
"}]," +
"\"final\":false," +
"\"participant\":\"p0\"," +
"\"request\":{" +
"\"application\":\"a0\"," +
"\"from\":\"2022-01-01T00:00:00Z\"," +
"\"to\":\"2022-01-01T00:00:00Z\"" +
"}" +
"}",
)
}
}
}