-
Notifications
You must be signed in to change notification settings - Fork 23
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(pollux): implement revocation BitString and VC SL 2021 generation (
#797) Signed-off-by: Benjamin Voiturier <[email protected]> Signed-off-by: Shota Jolbordi <[email protected]>
- Loading branch information
1 parent
892d078
commit 6a2f314
Showing
4 changed files
with
334 additions
and
0 deletions.
There are no files selected for viewing
81 changes: 81 additions & 0 deletions
81
pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitString.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
85 changes: 85 additions & 0 deletions
85
...x/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
94 changes: 94 additions & 0 deletions
94
pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitStringSpec.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]) | ||
} | ||
) | ||
|
||
} |
74 changes: 74 additions & 0 deletions
74
...b/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021Spec.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
) | ||
} |