From 37f75733b69f3dabf0e36f47bcdc2c2db9daec6e Mon Sep 17 00:00:00 2001 From: sklump Date: Wed, 30 Sep 2020 15:28:43 +0000 Subject: [PATCH] check ledger for rev reg delta evidence of cred revocation Signed-off-by: sklump --- aries_cloudagent/holder/base.py | 11 ++ aries_cloudagent/holder/indy.py | 22 ++- aries_cloudagent/holder/routes.py | 52 ++++++- aries_cloudagent/holder/tests/test_indy.py | 149 ++++++++++++++----- aries_cloudagent/holder/tests/test_routes.py | 78 +++++++++- aries_cloudagent/revocation/routes.py | 2 +- 6 files changed, 274 insertions(+), 40 deletions(-) diff --git a/aries_cloudagent/holder/base.py b/aries_cloudagent/holder/base.py index a99f74f5a4..3d40878e22 100644 --- a/aries_cloudagent/holder/base.py +++ b/aries_cloudagent/holder/base.py @@ -4,6 +4,7 @@ from typing import Tuple, Union from ..core.error import BaseError +from ..ledger.base import BaseLedger class HolderError(BaseError): @@ -33,6 +34,16 @@ async def get_credential(self, credential_id: str) -> str: """ + @abstractmethod + async def credential_revoked(self, credential_id: str, ledger: BaseLedger) -> bool: + """ + Check ledger for revocation status of credential by cred id. + + Args: + credential_id: Credential id to check + + """ + @abstractmethod async def delete_credential(self, credential_id: str): """ diff --git a/aries_cloudagent/holder/indy.py b/aries_cloudagent/holder/indy.py index 089a59f9f9..5b21efaa1a 100644 --- a/aries_cloudagent/holder/indy.py +++ b/aries_cloudagent/holder/indy.py @@ -11,10 +11,10 @@ from ..indy import create_tails_reader from ..indy.error import IndyErrorHandler +from ..ledger.base import BaseLedger from ..storage.indy import IndyStorage from ..storage.error import StorageError, StorageNotFoundError from ..storage.record import StorageRecord - from ..wallet.error import WalletNotFoundError from .base import BaseHolder, HolderError @@ -288,6 +288,26 @@ async def get_credential(self, credential_id: str) -> str: return credential_json + async def credential_revoked(self, credential_id: str, ledger: BaseLedger) -> bool: + """ + Check ledger for revocation status of credential by cred id. + + Args: + credential_id: Credential id to check + ledger: ledger to open and query + + """ + cred = json.loads(await self.get_credential(credential_id)) + rev_reg_id = cred["rev_reg_id"] + + if rev_reg_id: + cred_rev_id = int(cred["cred_rev_id"]) + (rev_reg_delta, _) = await ledger.get_revoc_reg_delta(rev_reg_id) + + return cred_rev_id in rev_reg_delta["value"].get("revoked", []) + else: + return False + async def delete_credential(self, credential_id: str): """ Remove a credential stored in the wallet. diff --git a/aries_cloudagent/holder/routes.py b/aries_cloudagent/holder/routes.py index a99f2f6577..7ffc2f566c 100644 --- a/aries_cloudagent/holder/routes.py +++ b/aries_cloudagent/holder/routes.py @@ -6,7 +6,8 @@ from aiohttp_apispec import docs, match_info_schema, querystring_schema, response_schema from marshmallow import fields -from .base import BaseHolder, HolderError +from ..ledger.base import BaseLedger +from ..ledger.error import LedgerError from ..messaging.models.openapi import OpenAPISchema from ..messaging.valid import ( INDY_CRED_DEF_ID, @@ -19,6 +20,7 @@ UUIDFour, ) from ..wallet.error import WalletNotFoundError +from .base import BaseHolder, HolderError class AttributeMimeTypesResultSchema(OpenAPISchema): @@ -108,6 +110,12 @@ class CredIdMatchInfoSchema(OpenAPISchema): ) +class CredRevokedResultSchema(OpenAPISchema): + """Result schema for credential revoked request.""" + + revoked = fields.Bool(description="Whether credential is revoked on the ledger") + + @docs(tags=["credentials"], summary="Fetch a credential from wallet by id") @match_info_schema(CredIdMatchInfoSchema()) @response_schema(CredBriefSchema(), 200) @@ -136,6 +144,43 @@ async def credentials_get(request: web.BaseRequest): return web.json_response(credential_json) +@docs(tags=["credentials"], summary="Query credential revocation status by id") +@match_info_schema(CredIdMatchInfoSchema()) +@response_schema(CredRevokedResultSchema(), 200) +async def credentials_revoked(request: web.BaseRequest): + """ + Request handler for querying revocation status of credential. + + Args: + request: aiohttp request object + + Returns: + The credential response + + """ + context = request.app["request_context"] + + credential_id = request.match_info["credential_id"] + + ledger: BaseLedger = await context.inject(BaseLedger, required=False) + if not ledger: + reason = "No ledger available" + if not context.settings.get_value("wallet.type"): + reason += ": missing wallet-type?" + raise web.HTTPForbidden(reason=reason) + + async with ledger: + try: + holder: BaseHolder = await context.inject(BaseHolder) + revoked = await holder.credential_revoked(credential_id, ledger) + except WalletNotFoundError as err: + raise web.HTTPNotFound(reason=err.roll_up) from err + except LedgerError as err: + raise web.HTTPBadRequest(reason=err.roll_up) from err + + return web.json_response({"revoked": revoked}) + + @docs(tags=["credentials"], summary="Get attribute MIME types from wallet") @match_info_schema(CredIdMatchInfoSchema()) @response_schema(AttributeMimeTypesResultSchema(), 200) @@ -228,6 +273,11 @@ async def register(app: web.Application): app.add_routes( [ web.get("/credential/{credential_id}", credentials_get, allow_head=False), + web.get( + "/credential/revoked/{credential_id}", + credentials_revoked, + allow_head=False, + ), web.get( "/credential/mime-types/{credential_id}", credentials_attr_mime_types_get, diff --git a/aries_cloudagent/holder/tests/test_indy.py b/aries_cloudagent/holder/tests/test_indy.py index b55e92d942..4721bc52c5 100644 --- a/aries_cloudagent/holder/tests/test_indy.py +++ b/aries_cloudagent/holder/tests/test_indy.py @@ -8,20 +8,19 @@ import indy.anoncreds from indy.error import IndyError, ErrorCode -import aries_cloudagent.holder.indy as test_module -from aries_cloudagent.holder.indy import IndyHolder -from aries_cloudagent.storage.error import StorageError -from aries_cloudagent.storage.record import StorageRecord - +from ...storage.error import StorageError +from ...storage.record import StorageRecord from ...protocols.issue_credential.v1_0.messages.inner.credential_preview import ( CredentialPreview, ) +from .. import indy as test_module + @pytest.mark.indy class TestIndyHolder(AsyncTestCase): def test_init(self): - holder = IndyHolder("wallet") + holder = test_module.IndyHolder("wallet") assert holder.wallet == "wallet" assert "IndyHolder" in str(holder) @@ -30,7 +29,7 @@ async def test_create_credential_request(self, mock_create_credential_req): mock_create_credential_req.return_value = ("{}", "[]") mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) cred_req_json, cred_req_meta_json = await holder.create_credential_request( "credential_offer", "credential_definition", "did" ) @@ -50,7 +49,7 @@ async def test_store_credential(self, mock_store_cred): mock_store_cred.return_value = "cred_id" mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) cred_id = await holder.store_credential( "credential_definition", "credential_data", "credential_request_metadata" @@ -79,7 +78,7 @@ async def test_store_credential_with_mime_types(self, mock_store_cred): mock_store_cred.return_value = "cred_id" mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) CRED_DATA = {"values": {"cameo": "d29yZCB1cA=="}} cred_id = await holder.store_credential( @@ -106,7 +105,7 @@ async def test_get_credential_attrs_mime_types(self, mock_nonsec_get_wallet_reco cred_id = "credential_id" dummy_tags = {"a": "1", "b": "2"} dummy_rec = { - "type": IndyHolder.RECORD_TYPE_MIME_TYPES, + "type": test_module.IndyHolder.RECORD_TYPE_MIME_TYPES, "id": cred_id, "value": "value", "tags": dummy_tags, @@ -115,14 +114,14 @@ async def test_get_credential_attrs_mime_types(self, mock_nonsec_get_wallet_reco mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) mime_types = await holder.get_mime_type(cred_id) mock_nonsec_get_wallet_record.assert_called_once_with( mock_wallet.handle, dummy_rec["type"], - f"{IndyHolder.RECORD_TYPE_MIME_TYPES}::{dummy_rec['id']}", + f"{test_module.IndyHolder.RECORD_TYPE_MIME_TYPES}::{dummy_rec['id']}", json.dumps( {"retrieveType": False, "retrieveValue": True, "retrieveTags": True} ), @@ -135,7 +134,7 @@ async def test_get_credential_attr_mime_type(self, mock_nonsec_get_wallet_record cred_id = "credential_id" dummy_tags = {"a": "1", "b": "2"} dummy_rec = { - "type": IndyHolder.RECORD_TYPE_MIME_TYPES, + "type": test_module.IndyHolder.RECORD_TYPE_MIME_TYPES, "id": cred_id, "value": "value", "tags": dummy_tags, @@ -144,14 +143,14 @@ async def test_get_credential_attr_mime_type(self, mock_nonsec_get_wallet_record mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) a_mime_type = await holder.get_mime_type(cred_id, "a") mock_nonsec_get_wallet_record.assert_called_once_with( mock_wallet.handle, dummy_rec["type"], - f"{IndyHolder.RECORD_TYPE_MIME_TYPES}::{dummy_rec['id']}", + f"{test_module.IndyHolder.RECORD_TYPE_MIME_TYPES}::{dummy_rec['id']}", json.dumps( {"retrieveType": False, "retrieveValue": True, "retrieveTags": True} ), @@ -164,7 +163,7 @@ async def test_get_credential_attr_mime_type_x(self, mock_nonsec_get_wallet_reco cred_id = "credential_id" dummy_tags = {"a": "1", "b": "2"} dummy_rec = { - "type": IndyHolder.RECORD_TYPE_MIME_TYPES, + "type": test_module.IndyHolder.RECORD_TYPE_MIME_TYPES, "id": cred_id, "value": "value", "tags": dummy_tags, @@ -172,7 +171,7 @@ async def test_get_credential_attr_mime_type_x(self, mock_nonsec_get_wallet_reco mock_nonsec_get_wallet_record.side_effect = test_module.StorageError() mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) assert await holder.get_mime_type(cred_id, "a") is None @@ -185,12 +184,12 @@ async def test_get_credentials( SIZE = 300 mock_search_credentials.return_value = ("search_handle", 350) mock_fetch_credentials.side_effect = [ - json.dumps([0] * IndyHolder.CHUNK), - json.dumps([1] * (SIZE % IndyHolder.CHUNK)), + json.dumps([0] * test_module.IndyHolder.CHUNK), + json.dumps([1] * (SIZE % test_module.IndyHolder.CHUNK)), ] mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) credentials = await holder.get_credentials(0, SIZE, {}) @@ -204,8 +203,8 @@ async def test_get_credentials( assert len(credentials) == SIZE mock_fetch_credentials.side_effect = [ - json.dumps([0] * IndyHolder.CHUNK), - json.dumps([1] * (SIZE % IndyHolder.CHUNK)), + json.dumps([0] * test_module.IndyHolder.CHUNK), + json.dumps([1] * (SIZE % test_module.IndyHolder.CHUNK)), ] credentials = await holder.get_credentials(0, 0, {}) # check 0 default to all assert len(credentials) == SIZE @@ -220,7 +219,7 @@ async def test_get_credentials_seek( mock_fetch_credentials.return_value = "[1,2,3]" mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) credentials = await holder.get_credentials(2, 3, {}) @@ -246,19 +245,23 @@ async def test_get_credentials_for_presentation_request_by_referent( json.dumps( [ {"cred_info": {"referent": f"reft-{i}"}} - for i in range(IndyHolder.CHUNK) + for i in range(test_module.IndyHolder.CHUNK) ] ), json.dumps( [ - {"cred_info": {"referent": f"reft-{IndyHolder.CHUNK + i}"}} - for i in range(SIZE % IndyHolder.CHUNK) + { + "cred_info": { + "referent": f"reft-{test_module.IndyHolder.CHUNK + i}" + } + } + for i in range(SIZE % test_module.IndyHolder.CHUNK) ] ), ] mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) credentials = await holder.get_credentials_for_presentation_request_by_referent( {"proof": "req"}, ("asdb",), 50, SIZE, {"extra": "query"} @@ -293,7 +296,7 @@ async def test_get_credentials_for_presentation_request_by_referent_default_reft ) mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) PRES_REQ = { "requested_attributes": { @@ -319,7 +322,7 @@ async def test_get_credential(self, mock_get_cred): mock_get_cred.return_value = "{}" mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) credential_json = await holder.get_credential("credential_id") @@ -332,7 +335,7 @@ async def test_get_credential_not_found(self, mock_get_cred): mock_get_cred.side_effect = IndyError(error_code=ErrorCode.WalletItemNotFound) mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) with self.assertRaises(test_module.WalletNotFoundError): await holder.get_credential("credential_id") @@ -342,11 +345,87 @@ async def test_get_credential_x(self, mock_get_cred): mock_get_cred.side_effect = IndyError("unexpected failure") mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) with self.assertRaises(test_module.HolderError): await holder.get_credential("credential_id") + async def test_credential_revoked(self): + mock_wallet = async_mock.MagicMock() + holder = test_module.IndyHolder(mock_wallet) + + ledger = async_mock.MagicMock() + ledger.__aenter__ = async_mock.CoroutineMock(return_value=ledger) + ledger.get_revoc_reg_delta = async_mock.CoroutineMock( + return_value=( + {"value": {"...": "..."}}, + 1234567890, + ) + ) + + with async_mock.patch.object( # no creds revoked + holder, "get_credential", async_mock.CoroutineMock() + ) as mock_get_cred: + mock_get_cred.return_value = json.dumps( + { + "rev_reg_id": "dummy-rrid", + "cred_rev_id": "123", + "...": "...", + } + ) + result = await holder.credential_revoked("credential_id", ledger) + assert not result + + with async_mock.patch.object( # cred not revocable + holder, "get_credential", async_mock.CoroutineMock() + ) as mock_get_cred: + mock_get_cred.return_value = json.dumps( + { + "rev_reg_id": None, + "cred_rev_id": None, + "...": "...", + } + ) + result = await holder.credential_revoked("credential_id", ledger) + assert not result + + ledger.get_revoc_reg_delta = async_mock.CoroutineMock( + return_value=( + { + "value": { + "revoked": [1, 2, 3], + "...": "...", + } + }, + 1234567890, + ) + ) + with async_mock.patch.object( # cred not revoked + holder, "get_credential", async_mock.CoroutineMock() + ) as mock_get_cred: + mock_get_cred.return_value = json.dumps( + { + "rev_reg_id": "dummy-rrid", + "cred_rev_id": "123", + "...": "...", + } + ) + result = await holder.credential_revoked("credential_id", ledger) + assert not result + + with async_mock.patch.object( # cred revoked + holder, "get_credential", async_mock.CoroutineMock() + ) as mock_get_cred: + mock_get_cred.return_value = json.dumps( + { + "rev_reg_id": "dummy-rrid", + "cred_rev_id": "2", + "...": "...", + } + ) + result = await holder.credential_revoked("credential_id", ledger) + assert result + @async_mock.patch("indy.anoncreds.prover_delete_credential") @async_mock.patch("indy.non_secrets.get_wallet_record") @async_mock.patch("indy.non_secrets.delete_wallet_record") @@ -357,7 +436,7 @@ async def test_delete_credential( mock_prover_del_cred, ): mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) mock_nonsec_get_wallet_record.return_value = json.dumps( { "type": "typ", @@ -383,7 +462,7 @@ async def test_delete_credential_x( mock_prover_del_cred, ): mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) mock_nonsec_get_wallet_record.side_effect = test_module.StorageNotFoundError() mock_prover_del_cred.side_effect = IndyError( @@ -408,7 +487,7 @@ async def test_create_presentation(self, mock_create_proof): mock_create_proof.return_value = "{}" mock_wallet = async_mock.MagicMock() - holder = IndyHolder(mock_wallet) + holder = test_module.IndyHolder(mock_wallet) presentation_json = await holder.create_presentation( "presentation_request", @@ -435,7 +514,7 @@ async def test_create_revocation_state(self): "rev_reg": {"accum": "21 ..."}, "timestamp": 1234567890, } - holder = IndyHolder("wallet") + holder = test_module.IndyHolder("wallet") with async_mock.patch.object( test_module, "create_tails_reader", async_mock.CoroutineMock() diff --git a/aries_cloudagent/holder/tests/test_routes.py b/aries_cloudagent/holder/tests/test_routes.py index 1b223c386e..662de6a934 100644 --- a/aries_cloudagent/holder/tests/test_routes.py +++ b/aries_cloudagent/holder/tests/test_routes.py @@ -7,10 +7,11 @@ from aiohttp.web import HTTPForbidden from ...config.injection_context import InjectionContext +from ...ledger.base import BaseLedger +from ...ledger.error import LedgerError from ...wallet.base import BaseWallet from .. import routes as test_module -from ..base import BaseHolder class TestHolderRoutes(AsyncTestCase): @@ -20,7 +21,6 @@ def setUp(self): self.wallet = async_mock.create_autospec(BaseWallet) self.context.injector.bind_instance(BaseWallet, self.wallet) - self.holder = async_mock.create_autospec(BaseHolder) self.app = { "request_context": self.context, } @@ -59,6 +59,80 @@ async def test_credentials_get_not_found(self): with self.assertRaises(test_module.web.HTTPNotFound): await test_module.credentials_get(request) + async def test_credentials_revoked(self): + request = async_mock.MagicMock( + app=self.app, match_info={"credential_id": "dummy"} + ) + ledger = async_mock.create_autospec(BaseLedger) + + request.app["request_context"].inject = async_mock.CoroutineMock( + side_effect=[ + ledger, + async_mock.MagicMock( + credential_revoked=async_mock.CoroutineMock(return_value=False) + ), + ] + ) + + with async_mock.patch.object( + test_module.web, "json_response", async_mock.Mock() + ) as json_response: + result = await test_module.credentials_revoked(request) + json_response.assert_called_once_with({"revoked": False}) + assert result is json_response.return_value + + async def test_credentials_revoked_no_ledger(self): + request = async_mock.MagicMock( + app=self.app, match_info={"credential_id": "dummy"} + ) + + request.app["request_context"].inject = async_mock.CoroutineMock( + return_value=None + ) + + with self.assertRaises(test_module.web.HTTPForbidden): + await test_module.credentials_revoked(request) + + async def test_credentials_not_found(self): + request = async_mock.MagicMock( + app=self.app, match_info={"credential_id": "dummy"} + ) + ledger = async_mock.create_autospec(BaseLedger) + + request.app["request_context"].inject = async_mock.CoroutineMock( + side_effect=[ + ledger, + async_mock.MagicMock( + credential_revoked=async_mock.CoroutineMock( + side_effect=test_module.WalletNotFoundError("no such cred") + ) + ), + ] + ) + + with self.assertRaises(test_module.web.HTTPNotFound): + await test_module.credentials_revoked(request) + + async def test_credentials_x_ledger(self): + request = async_mock.MagicMock( + app=self.app, match_info={"credential_id": "dummy"} + ) + ledger = async_mock.create_autospec(BaseLedger) + + request.app["request_context"].inject = async_mock.CoroutineMock( + side_effect=[ + ledger, + async_mock.MagicMock( + credential_revoked=async_mock.CoroutineMock( + side_effect=test_module.LedgerError("down for maintenance") + ) + ), + ] + ) + + with self.assertRaises(test_module.web.HTTPBadRequest): + await test_module.credentials_revoked(request) + async def test_attribute_mime_types_get(self): request = async_mock.MagicMock( app=self.app, match_info={"credential_id": "dummy"} diff --git a/aries_cloudagent/revocation/routes.py b/aries_cloudagent/revocation/routes.py index 9a3bd25dab..cc2f7ffc36 100644 --- a/aries_cloudagent/revocation/routes.py +++ b/aries_cloudagent/revocation/routes.py @@ -171,7 +171,7 @@ class RevRegsCreatedSchema(OpenAPISchema): """Result schema for request for revocation registries created.""" rev_reg_ids = fields.List( - fields.Str(description="Revocation Registry identifiers", **INDY_REV_REG_ID) + fields.Str(description="Revocation registry identifiers", **INDY_REV_REG_ID) )