Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for revocable credentials in vc_di handler #2967

Merged
merged 11 commits into from
Jun 10, 2024
95 changes: 86 additions & 9 deletions aries_cloudagent/anoncreds/revocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import os
import time
from pathlib import Path
from typing import List, NamedTuple, Optional, Sequence, Tuple
from typing import List, NamedTuple, Optional, Sequence, Tuple, Union
from urllib.parse import urlparse

import base58
Expand All @@ -19,6 +19,7 @@
RevocationRegistryDefinition,
RevocationRegistryDefinitionPrivate,
RevocationStatusList,
W3cCredential,
)
from aries_askar.error import AskarError
from requests import RequestException, Session
Expand Down Expand Up @@ -717,6 +718,7 @@ async def upload_tails_file(self, rev_reg_def: RevRegDef):
backoff=-0.5,
max_attempts=5, # heuristic: respect HTTP timeout
)

if not upload_success:
raise AnonCredsRevocationError(
f"Tails file for rev reg for {rev_reg_def.cred_def_id} "
Expand Down Expand Up @@ -892,16 +894,61 @@ async def get_or_create_active_registry(self, cred_def_id: str) -> RevRegDefResu

# Credential Operations

async def create_credential_w3c(
self,
w3c_credential_offer: dict,
w3c_credential_request: dict,
w3c_credential_values: dict,
*,
retries: int = 5,
) -> Tuple[str, str, str]:
"""Create a w3c_credential.

Args:
w3c_credential_offer: Credential Offer to create w3c_credential for
w3c_credential_request: Credential request to create w3c_credential for
w3c_credential_values: Values to go in w3c_credential
retries: number of times to retry w3c_credential creation

Returns:
A tuple of created w3c_credential and revocation id

"""
return await self._create_credential_helper(
w3c_credential_offer,
w3c_credential_request,
w3c_credential_values,
W3cCredential,
retries=retries,
)

async def _create_credential(
self,
credential_definition_id: str,
schema_attributes: List[str],
credential_offer: dict,
credential_request: dict,
credential_values: dict,
credential_type: Union[Credential, W3cCredential],
rev_reg_def_id: Optional[str] = None,
tails_file_path: Optional[str] = None,
) -> Tuple[str, str]:
"""Create a credential.

Args:
credential_definition_id: The credential definition ID
schema_attributes: The schema attributes
credential_offer: The credential offer
credential_request: The credential request
credential_values: The credential values
credential_type: The credential type
rev_reg_def_id: The revocation registry definition ID
tails_file_path: The tails file path

Returns:
A tuple of created credential and revocation ID

"""
try:
async with self.profile.session() as session:
cred_def = await session.handle.fetch(
Expand Down Expand Up @@ -1004,14 +1051,13 @@ async def _create_credential(
try:
credential = await asyncio.get_event_loop().run_in_executor(
None,
lambda: Credential.create(
cred_def.raw_value,
cred_def_private.raw_value,
credential_offer,
credential_request,
raw_values,
None,
revoc,
lambda: credential_type.create(
cred_def=cred_def.raw_value,
cred_def_private=cred_def_private.raw_value,
cred_offer=credential_offer,
cred_request=credential_request,
attr_raw_values=raw_values,
revocation_config=revoc,
),
)
except AnoncredsError as err:
Expand Down Expand Up @@ -1039,6 +1085,36 @@ async def create_credential(
Returns:
A tuple of created credential and revocation id

"""
return await self._create_credential_helper(
credential_offer,
credential_request,
credential_values,
Credential,
retries=retries,
)

async def _create_credential_helper(
self,
credential_offer: dict,
credential_request: dict,
credential_values: dict,
credential_type: Union[Credential, W3cCredential],
*,
retries: int = 5,
) -> Tuple[str, str, str]:
"""Create a credential.

Args:
credential_offer: Credential Offer to create credential for
credential_request: Credential request to create credential for
credential_values: Values to go in credential
credential_type: Credential or W3cCredential
retries: number of times to retry credential creation

Returns:
A tuple of created credential and revocation id

"""
issuer = AnonCredsIssuer(self.profile)
anoncreds_registry = self.profile.inject(AnonCredsRegistry)
Expand Down Expand Up @@ -1081,6 +1157,7 @@ async def create_credential(
credential_offer,
credential_request,
credential_values,
credential_type,
rev_reg_def_id,
tails_file_path,
)
Expand Down
88 changes: 88 additions & 0 deletions aries_cloudagent/anoncreds/tests/test_revocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
RevocationRegistryDefinitionPrivate,
RevocationStatusList,
Schema,
# AnoncredsError,
# W3cCredential,
# CredentialRevocationConfig,
)
from aries_askar import AskarError, AskarErrorCode
from requests import RequestException, Session
Expand Down Expand Up @@ -1024,6 +1027,7 @@ async def test_create_credential_private_no_rev_reg_or_tails(
"attr1": "value1",
"attr2": "value2",
},
credential_type=Credential,
)
assert mock_create.called

Expand All @@ -1038,6 +1042,7 @@ async def test_create_credential_private_no_rev_reg_or_tails(
credential_offer={},
credential_request={},
credential_values={},
credential_type=Credential,
)

# missing cred def or cred def private
Expand All @@ -1049,6 +1054,7 @@ async def test_create_credential_private_no_rev_reg_or_tails(
credential_offer={},
credential_request={},
credential_values={},
credential_type=Credential,
)
mock_handle.fetch = mock.CoroutineMock(side_effect=[MockEntry(), None])
with self.assertRaises(test_module.AnonCredsRevocationError):
Expand All @@ -1058,6 +1064,7 @@ async def test_create_credential_private_no_rev_reg_or_tails(
credential_offer={},
credential_request={},
credential_values={},
credential_type=Credential,
)

@mock.patch.object(InMemoryProfileSession, "handle")
Expand Down Expand Up @@ -1086,6 +1093,7 @@ async def call_test_func():
},
rev_reg_def_id="test-rev-reg-def-id",
tails_file_path="tails-file-path",
credential_type=Credential,
)

# missing rev list
Expand Down Expand Up @@ -1380,3 +1388,83 @@ async def test_clear_pending_revocations_with_non_anoncreds_session(self):
await self.revocation.clear_pending_revocations(
self.profile.session(), rev_reg_def_id="test-rev-reg-id"
)

@mock.patch.object(
AnonCredsIssuer, "cred_def_supports_revocation", return_value=True
)
async def test_create_credential_w3c(self, mock_supports_revocation):
self.profile.inject = mock.Mock(
return_value=mock.MagicMock(
get_schema=mock.CoroutineMock(
return_value=GetSchemaResult(
schema_id="CsQY9MGeD3CQP4EyuVFo5m:2:MYCO Biomarker:0.0.3",
schema=AnonCredsSchema(
issuer_id="CsQY9MGeD3CQP4EyuVFo5m",
name="MYCO Biomarker:0.0.3",
version="1.0",
attr_names=["attr1", "attr2"],
),
schema_metadata={},
resolution_metadata={},
)
)
)
)
self.revocation.get_or_create_active_registry = mock.CoroutineMock(
return_value=RevRegDefResult(
job_id="test-job-id",
revocation_registry_definition_state=RevRegDefState(
state=RevRegDefState.STATE_FINISHED,
revocation_registry_definition_id="active-reg-reg",
revocation_registry_definition=rev_reg_def,
),
registration_metadata={},
revocation_registry_definition_metadata={},
)
)

# Test private funtion seperately - very large
self.revocation._create_credential = mock.CoroutineMock(
return_value=({"cred": "cred"}, 98)
)

result = await self.revocation.create_credential_w3c(
w3c_credential_offer={
"schema_id": "CsQY9MGeD3CQP4EyuVFo5m:2:MYCO Biomarker:0.0.3",
"cred_def_id": "CsQY9MGeD3CQP4EyuVFo5m:3:CL:14951:MYCO_Biomarker",
"key_correctness_proof": {},
"nonce": "nonce",
},
w3c_credential_request={},
w3c_credential_values={},
)

assert isinstance(result, tuple)
assert mock_supports_revocation.call_count == 1

@pytest.mark.asyncio
@mock.patch.object(InMemoryProfileSession, "handle")
async def test_create_credential_w3c_keyerror(self, mock_handle):
mock_handle.fetch = mock.CoroutineMock(side_effect=[MockEntry(), MockEntry()])
with pytest.raises(test_module.AnonCredsRevocationError) as excinfo:
await self.revocation._create_credential(
credential_definition_id="test-cred-def-id",
schema_attributes=["attr1", "attr2"],
credential_offer={
"schema_id": "CsQY9MGeD3CQP4EyuVFo5m:2:MYCO Biomarker:0.0.3",
"cred_def_id": "CsQY9MGeD3CQP4EyuVFo5m:3:CL:14951:MYCO_Biomarker",
"key_correctness_proof": {},
"nonce": "nonce",
},
credential_request={},
credential_values={
"X": "value1",
"Y": "value2",
},
credential_type=Credential,
)

assert str(excinfo.value) == (
"Provided credential values are missing a value "
"for the schema attribute 'attr1'"
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import json
import logging
from typing import Mapping, Tuple

from anoncreds import W3cCredential
from ...models.cred_ex_record import V20CredExRecord
from ...models.detail.indy import (
V20CredExRecordIndy,
Expand Down Expand Up @@ -460,25 +460,36 @@ async def issue_credential(

async with ledger:
schema_id = await ledger.credential_definition_id2schema_id(cred_def_id)
cred_def = await ledger.get_credential_definition(cred_def_id)
revocable = cred_def["value"].get("revocation")

legacy_offer = await self._prepare_legacy_offer(cred_offer, schema_id)
legacy_request = await self._prepare_legacy_request(cred_request, cred_def_id)

issuer = AnonCredsIssuer(self.profile)

credential = await issuer.create_credential_w3c(
legacy_offer, legacy_request, cred_values
)
cred_rev_id = None
rev_reg_def_id = None
credential = None
if revocable:
issuer = AnonCredsRevocation(self.profile)
(
credential,
cred_rev_id,
rev_reg_def_id,
) = await issuer.create_credential_w3c(
legacy_offer, legacy_request, cred_values
)
else:
issuer = AnonCredsIssuer(self.profile)
credential = await issuer.create_credential_w3c(
legacy_offer, legacy_request, cred_values
)

vcdi_credential = {
"credential": json.loads(credential),
}

result = self.get_format_data(CRED_20_ISSUE, vcdi_credential)

cred_rev_id = None
rev_reg_def_id = None

async with self._profile.transaction() as txn:
detail_record = V20CredExRecordIndy(
cred_ex_id=cred_ex_record.cred_ex_id,
Expand Down Expand Up @@ -554,10 +565,18 @@ async def store_credential(
cred_def_result = await anoncreds_registry.get_credential_definition(
self.profile, cred["proof"][0]["verificationMethod"]
)
if cred["proof"][0].get("rev_reg_id"):
# TODO: remove loading of W3cCredential and use the credential directly
try:
cred_w3c = W3cCredential.load(cred)
rev_reg_id = cred_w3c.rev_reg_id
rev_reg_index = cred_w3c.rev_reg_index
except AnonCredsHolderError as e:
LOGGER.error(f"Error receiving credential: {e.error_code} - {e.message}")
raise e
if rev_reg_id:
rev_reg_def_result = (
await anoncreds_registry.get_revocation_registry_definition(
self.profile, cred["proof"][0]["rev_reg_id"]
self.profile, rev_reg_id
)
)
rev_reg_def = rev_reg_def_result.revocation_registry
Expand Down Expand Up @@ -588,8 +607,8 @@ async def store_credential(
)

detail_record.cred_id_stored = cred_id_stored
detail_record.rev_reg_id = cred["proof"][0].get("rev_reg_id", None)
detail_record.cred_rev_id = cred["proof"][0].get("cred_rev_id", None)
detail_record.rev_reg_id = rev_reg_id
detail_record.cred_rev_id = rev_reg_index

async with self.profile.session() as session:
# Store detail record, emit event
Expand Down
Loading
Loading