Skip to content

Commit

Permalink
feat(pollux): implement revocation BitString and VC SL 2021 generation (
Browse files Browse the repository at this point in the history
#797)

Signed-off-by: Benjamin Voiturier <[email protected]>
Signed-off-by: Shota Jolbordi <[email protected]>
  • Loading branch information
bvoiturier authored and Shota Jolbordi committed Dec 5, 2023
1 parent 892d078 commit 6a2f314
Show file tree
Hide file tree
Showing 4 changed files with 334 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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])
}
)

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

0 comments on commit 6a2f314

Please sign in to comment.