From 6a2f314d39bf84ad7c622dcbcf4e92159443eb1f Mon Sep 17 00:00:00 2001 From: bvoiturier Date: Mon, 27 Nov 2023 14:04:47 +0100 Subject: [PATCH] feat(pollux): implement revocation BitString and VC SL 2021 generation (#797) Signed-off-by: Benjamin Voiturier Signed-off-by: Shota Jolbordi --- .../pollux/vc/jwt/revocation/BitString.scala | 81 ++++++++++++++++ .../vc/jwt/revocation/VCStatusList2021.scala | 85 +++++++++++++++++ .../vc/jwt/revocation/BitStringSpec.scala | 94 +++++++++++++++++++ .../jwt/revocation/VCStatusList2021Spec.scala | 74 +++++++++++++++ 4 files changed, 334 insertions(+) create mode 100644 pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitString.scala create mode 100644 pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021.scala create mode 100644 pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitStringSpec.scala create mode 100644 pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021Spec.scala diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitString.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitString.scala new file mode 100644 index 0000000000..6d4becb464 --- /dev/null +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitString.scala @@ -0,0 +1,81 @@ +package io.iohk.atala.pollux.vc.jwt.revocation + +import io.iohk.atala.pollux.vc.jwt.revocation.BitStringError.{DecodingError, EncodingError, IndexOutOfBounds} +import zio.{IO, UIO, ZIO} + +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} +import java.util +import java.util.Base64 +import java.util.zip.{GZIPInputStream, GZIPOutputStream} + +class BitString private (val bitSet: util.BitSet, val size: Int) { + def setRevoked(index: Int, value: Boolean): IO[IndexOutOfBounds, Unit] = + if (index >= size) ZIO.fail(IndexOutOfBounds(s"bitIndex >= $size: $index")) + else ZIO.attempt(bitSet.set(index, value)).mapError(t => IndexOutOfBounds(t.getMessage)) + + def isRevoked(index: Int): IO[IndexOutOfBounds, Boolean] = + if (index >= size) ZIO.fail(IndexOutOfBounds(s"bitIndex >= $size: $index")) + else ZIO.attempt(bitSet.get(index)).mapError(t => IndexOutOfBounds(t.getMessage)) + + def revokedCount(): UIO[Int] = ZIO.succeed(bitSet.stream().count().toInt) + + def encoded: IO[EncodingError, String] = { + for { + bitSetByteArray <- ZIO.succeed(bitSet.toByteArray) + /* + This is where the size constructor parameter comes into play (i.e. the initial bitstring size requested by the user). + Interestingly, the underlying 'bitSet.toByteArray()' method only returns the byte array that are 'in use', which means the bytes needed to hold the current bits that are set to true. + E.g. Calling toByteArray on a BitSet of size 64, where all bits are false, will return an empty array. The same BitSet with the fourth bit set to true will return 1 byte. And so on... + So, the paddingByteArray is used to fill the gap between what BitSet returns and what was requested by the user. + If the BitString size is 131.072 and no VC is revoked, the final encoding (as per the spec) should account for all bits, and no only those that are revoked. + The (x + 7) / 8) is used to calculate the number of bytes needed to store a bit array of size x. + */ + paddingByteArray = new Array[Byte](((size + 7) / 8) - bitSetByteArray.length) + baos = new ByteArrayOutputStream() + _ <- (for { + gzipOutputStream <- ZIO.attempt(new GZIPOutputStream(baos)) + _ <- ZIO.attempt(gzipOutputStream.write(bitSetByteArray)) + _ <- ZIO.attempt(gzipOutputStream.write(paddingByteArray)) + _ <- ZIO.attempt(gzipOutputStream.close()) + } yield ()).mapError(t => EncodingError(t.getMessage)) + } yield { + Base64.getUrlEncoder.encodeToString(baos.toByteArray) + } + } +} + +object BitString { + /* + The minimum size of the bit string according to the VC Status List 2021 specification. + As per the spec "... a minimum revocation bitstring of 131.072, or 16KB uncompressed... is enough to give holders an adequate amount of herd privacy" + Cf. https://www.w3.org/TR/vc-status-list/#revocation-bitstring-length + */ + val MIN_SL2021_SIZE: Int = 131072 + + def getInstance(): IO[BitStringError, BitString] = getInstance(MIN_SL2021_SIZE) + + def getInstance(size: Int): IO[BitStringError, BitString] = { + if (size % 8 != 0) ZIO.fail(BitStringError.InvalidSize("Bit string size should be a multiple of 8")) + else ZIO.succeed(BitString(new util.BitSet(size), size)) + } + + def valueOf(b64Value: String): IO[DecodingError, BitString] = { + for { + ba <- ZIO.attempt(Base64.getUrlDecoder.decode(b64Value)).mapError(t => DecodingError(t.getMessage)) + } yield { + val bais = new ByteArrayInputStream(ba) + val gzipInputStream = new GZIPInputStream(bais) + val byteArray = gzipInputStream.readAllBytes() + BitString(util.BitSet.valueOf(byteArray), byteArray.length * 8) + } + } +} + +sealed trait BitStringError + +object BitStringError { + final case class InvalidSize(message: String) extends BitStringError + final case class EncodingError(message: String) extends BitStringError + final case class DecodingError(message: String) extends BitStringError + final case class IndexOutOfBounds(message: String) extends BitStringError +} diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021.scala new file mode 100644 index 0000000000..6c69bf1674 --- /dev/null +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021.scala @@ -0,0 +1,85 @@ +package io.iohk.atala.pollux.vc.jwt.revocation + +import io.circe.syntax.* +import io.circe.{Json, JsonObject} +import io.iohk.atala.pollux.vc.jwt.* +import io.iohk.atala.pollux.vc.jwt.revocation.VCStatusList2021.Purpose.Revocation +import io.iohk.atala.pollux.vc.jwt.revocation.VCStatusList2021Error.{DecodingError, EncodingError} +import zio.{IO, UIO, ZIO} + +import java.time.Instant + +class VCStatusList2021 private (val vcPayload: W3cCredentialPayload, jwtIssuer: Issuer) { + + def encoded: UIO[JWT] = ZIO.succeed(W3CCredential.toEncodedJwt(vcPayload, jwtIssuer)) + + def getBitString: IO[DecodingError, BitString] = { + for { + encodedBitString <- ZIO + .fromOption( + vcPayload.credentialSubject.hcursor.downField("encodedList").as[String].toOption + ) + .mapError(_ => DecodingError("'encodedList' attribute not found in credential subject")) + bitString <- BitString.valueOf(encodedBitString).mapError(e => DecodingError(e.message)) + } yield bitString + } +} + +object VCStatusList2021 { + + enum Purpose(val name: String): + case Revocation extends Purpose("revocation") + case Suspension extends Purpose("suspension") + + def build( + vcId: String, + slId: String, + jwtIssuer: Issuer, + revocationData: BitString, + purpose: Purpose = Revocation + ): IO[EncodingError, VCStatusList2021] = { + for { + encodedBitString <- revocationData.encoded.mapError(e => EncodingError(e.message)) + } yield { + val claims = JsonObject() + .add("id", slId.asJson) + .add("type", "StatusList2021".asJson) + .add("statusPurpose", purpose.name.asJson) + .add("encodedList", encodedBitString.asJson) + val w3Credential = W3cCredentialPayload( + `@context` = Set( + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/vc/status-list/2021/v1" + ), + maybeId = Some(vcId), + `type` = Set("VerifiableCredential", "StatusList2021Credential"), + issuer = jwtIssuer.did, + issuanceDate = Instant.now, + maybeExpirationDate = None, + maybeCredentialSchema = None, + credentialSubject = claims.asJson, + maybeCredentialStatus = None, + maybeRefreshService = None, + maybeEvidence = None, + maybeTermsOfUse = None + ) + VCStatusList2021(w3Credential, jwtIssuer) + } + } + + def decode(encodedJwtVC: JWT, issuer: Issuer): IO[DecodingError, VCStatusList2021] = { + for { + jwtCredentialPayload <- ZIO + .fromTry(JwtCredential.decodeJwt(encodedJwtVC, issuer.publicKey)) + .mapError(t => DecodingError(t.getMessage)) + } yield VCStatusList2021(jwtCredentialPayload.toW3CCredentialPayload, issuer) + } + +} + +sealed trait VCStatusList2021Error + +object VCStatusList2021Error { + final case class EncodingError(msg: String) extends VCStatusList2021Error + final case class DecodingError(msg: String) extends VCStatusList2021Error +} diff --git a/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitStringSpec.scala b/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitStringSpec.scala new file mode 100644 index 0000000000..4fed3af4c9 --- /dev/null +++ b/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitStringSpec.scala @@ -0,0 +1,94 @@ +package io.iohk.atala.pollux.vc.jwt.revocation + +import io.iohk.atala.pollux.vc.jwt.revocation.BitStringError.{IndexOutOfBounds, InvalidSize} +import zio.* +import zio.test.* +import zio.test.Assertion.* + +object BitStringSpec extends ZIOSpecDefault { + + private val MIN_SIZE_SL2021_WITH_NO_REVOCATION = + "H4sIAAAAAAAA_-3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAIC3AYbSVKsAQAAA" + + override def spec = suite("Revocation BitString test suite")( + test("A default bit string instance has zero revoked items") { + for { + bitString <- BitString.getInstance() + revokedCount <- bitString.revokedCount() + } yield { + assertTrue(revokedCount == 0) + } + }, + test("A default bit string instance is correctly encoded/decoded") { + for { + initialBS <- BitString.getInstance() + encodedBS <- initialBS.encoded + decodedBS <- BitString.valueOf(encodedBS) + decodedRevokedCount <- decodedBS.revokedCount() + reencodedBS <- decodedBS.encoded + } yield { + assertTrue(encodedBS == MIN_SIZE_SL2021_WITH_NO_REVOCATION) + && assertTrue(decodedBS.size == BitString.MIN_SL2021_SIZE) + && assertTrue(decodedBS.size == initialBS.size) + && assertTrue(decodedRevokedCount == 0) + && assertTrue(encodedBS == reencodedBS) + } + }, + test("A bit string with custom size and revoked items is correctly encoded") { + for { + initialBS <- BitString.getInstance(800) + _ <- initialBS.setRevoked(753, true) + _ <- initialBS.setRevoked(45, true) + encodedBS <- initialBS.encoded + decodedBS <- BitString.valueOf(encodedBS) + decodedRevokedCount <- decodedBS.revokedCount() + isDecodedRevoked1 <- decodedBS.isRevoked(753) + isDecodedRevoked2 <- decodedBS.isRevoked(45) + isDecodedRevoked3 <- decodedBS.isRevoked(32) + } yield { + assertTrue(decodedRevokedCount == 2) + && assertTrue(isDecodedRevoked1) + && assertTrue(isDecodedRevoked2) + && assertTrue(!isDecodedRevoked3) + } + }, + test("A custom bit string size is a multiple of 8") { + for { + bitString <- BitString.getInstance(31).exit + } yield assert(bitString)(failsWithA[InvalidSize]) + }, + test("The first index is 0 and last index at 'size - 1'") { + for { + bitString <- BitString.getInstance(24) + _ <- bitString.setRevoked(0, true) + _ <- bitString.setRevoked(bitString.size - 1, true) + result <- bitString.setRevoked(bitString.size, true).exit + } yield assert(result)(failsWithA[IndexOutOfBounds]) + }, + test("Revoking with a negative index fails") { + for { + bitString <- BitString.getInstance(8) + result <- bitString.setRevoked(-1, true).exit + } yield assert(result)(failsWithA[IndexOutOfBounds]) + }, + test("Revoking with an index above the range fails") { + for { + bitString <- BitString.getInstance(8) + result <- bitString.setRevoked(20, false).exit + } yield assert(result)(failsWithA[IndexOutOfBounds]) + }, + test("Getting revocation state with a negative index fails") { + for { + bitString <- BitString.getInstance(8) + result <- bitString.isRevoked(-1).exit + } yield assert(result)(failsWithA[IndexOutOfBounds]) + }, + test("Getting revocation state with an index above the range fails") { + for { + bitString <- BitString.getInstance(8) + result <- bitString.isRevoked(20).exit + } yield assert(result)(failsWithA[IndexOutOfBounds]) + } + ) + +} diff --git a/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021Spec.scala b/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021Spec.scala new file mode 100644 index 0000000000..6f691aba69 --- /dev/null +++ b/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021Spec.scala @@ -0,0 +1,74 @@ +package io.iohk.atala.pollux.vc.jwt.revocation + +import io.iohk.atala.pollux.vc.jwt.{DID, ES256Signer, Issuer, JwtCredential} +import zio.test.{Spec, ZIOSpecDefault, assertTrue} +import zio.{UIO, ZIO} + +import java.security.spec.ECGenParameterSpec +import java.security.{KeyPairGenerator, SecureRandom} + +object VCStatusList2021Spec extends ZIOSpecDefault { + + private val VC_ID = "https://example.com/credentials/status/3" + + private def generateIssuer(): UIO[Issuer] = { + val keyGen = KeyPairGenerator.getInstance("EC") + val ecSpec = ECGenParameterSpec("secp256r1") + keyGen.initialize(ecSpec, SecureRandom()) + val keyPair = keyGen.generateKeyPair() + val privateKey = keyPair.getPrivate + val publicKey = keyPair.getPublic + ZIO.succeed( + Issuer( + did = DID("did:issuer:MDP8AsFhHzhwUvGNuYkX7T"), + signer = ES256Signer(privateKey), + publicKey = publicKey + ) + ) + } + + override def spec = suite("VCStatusList2021")( + test("Generate VC contains required fields in 'credentialSubject'") { + for { + issuer <- generateIssuer() + bitString <- BitString.getInstance() + statusList <- VCStatusList2021.build(VC_ID, s"$VC_ID#list", issuer, bitString) + encodedJwtVC <- statusList.encoded + jwtVCPayload <- ZIO.fromTry(JwtCredential.decodeJwt(encodedJwtVC, issuer.publicKey)) + credentialSubjectKeys <- ZIO.fromOption(jwtVCPayload.credentialSubject.hcursor.keys) + } yield { + assertTrue(credentialSubjectKeys.toSet == Set("id", "type", "statusPurpose", "encodedList")) + } + }, + test("Generated VC is valid") { + for { + issuer <- generateIssuer() + bitString <- BitString.getInstance() + statusList <- VCStatusList2021.build(VC_ID, s"$VC_ID#list", issuer, bitString) + encodedJwtVC <- statusList.encoded + _ <- ZIO.logInfo(s"$encodedJwtVC") + valid <- ZIO.succeed(JwtCredential.validateEncodedJwt(encodedJwtVC, issuer.publicKey)) + } yield { + assertTrue(valid) + } + }, + test("Revocation state is preserved during encoding/decoding") { + for { + issuer <- generateIssuer() + bitString <- BitString.getInstance() + _ <- bitString.setRevoked(1234, true) + statusList <- VCStatusList2021.build(VC_ID, s"$VC_ID#list", issuer, bitString) + encodedJwtVC <- statusList.encoded + decodedStatusList <- VCStatusList2021.decode(encodedJwtVC, issuer) + decodedBS <- decodedStatusList.getBitString + revokedCount <- decodedBS.revokedCount() + isRevoked1 <- decodedBS.isRevoked(1233) + isRevoked2 <- decodedBS.isRevoked(1234) + } yield { + assertTrue(revokedCount == 1) && + assertTrue(!isRevoked1) && + assertTrue(isRevoked2) + } + } + ) +}