Source code for aries_cloudagent.protocols.issue_credential.v2_0.routes

"""Credential exchange admin routes."""

import logging

from json.decoder import JSONDecodeError
from typing import Mapping

from aiohttp import web
from aiohttp_apispec import (
    docs,
    match_info_schema,
    querystring_schema,
    request_schema,
    response_schema,
)
from marshmallow import fields, validate, validates_schema, ValidationError

from ...out_of_band.v1_0.models.oob_record import OobRecord
from ....wallet.util import default_did_from_verkey
from ....admin.request_context import AdminRequestContext
from ....connections.models.conn_record import ConnRecord
from ....core.profile import Profile
from ....indy.holder import IndyHolderError
from ....indy.issuer import IndyIssuerError
from ....ledger.error import LedgerError
from ....messaging.decorators.attach_decorator import AttachDecorator
from ....messaging.models.base import BaseModelError
from ....messaging.models.openapi import OpenAPISchema
from ....messaging.valid import (
    INDY_CRED_DEF_ID,
    INDY_DID,
    INDY_SCHEMA_ID,
    INDY_VERSION,
    UUIDFour,
    UUID4,
)
from ....storage.error import StorageError, StorageNotFoundError
from ....utils.tracing import trace_event, get_timer, AdminAPIMessageTracingSchema
from ....vc.ld_proofs.error import LinkedDataProofException

from . import problem_report_for_record, report_problem
from .manager import V20CredManager, V20CredManagerError
from .message_types import ATTACHMENT_FORMAT, CRED_20_PROPOSAL, SPEC_URI
from .messages.cred_format import V20CredFormat
from .messages.cred_problem_report import ProblemReportReason
from .messages.cred_proposal import V20CredProposal
from .messages.inner.cred_preview import V20CredPreview, V20CredPreviewSchema
from .models.cred_ex_record import V20CredExRecord, V20CredExRecordSchema
from .models.detail.ld_proof import V20CredExRecordLDProofSchema
from .models.detail.indy import V20CredExRecordIndySchema
from .formats.handler import V20CredFormatError
from .formats.ld_proof.models.cred_detail import LDProofVCDetailSchema

LOGGER = logging.getLogger(__name__)


[docs]class V20IssueCredentialModuleResponseSchema(OpenAPISchema): """Response schema for v2.0 Issue Credential Module."""
[docs]class V20CredExRecordListQueryStringSchema(OpenAPISchema): """Parameters and validators for credential exchange record list query.""" connection_id = fields.UUID( description="Connection identifier", required=False, example=UUIDFour.EXAMPLE, # typically but not necessarily a UUID4 ) thread_id = fields.UUID( description="Thread identifier", required=False, example=UUIDFour.EXAMPLE, # typically but not necessarily a UUID4 ) role = fields.Str( description="Role assigned in credential exchange", required=False, validate=validate.OneOf( [ getattr(V20CredExRecord, m) for m in vars(V20CredExRecord) if m.startswith("ROLE_") ] ), ) state = fields.Str( description="Credential exchange state", required=False, validate=validate.OneOf( [ getattr(V20CredExRecord, m) for m in vars(V20CredExRecord) if m.startswith("STATE_") ] ), )
[docs]class V20CredExRecordDetailSchema(OpenAPISchema): """Credential exchange record and any per-format details.""" cred_ex_record = fields.Nested( V20CredExRecordSchema, required=False, description="Credential exchange record", ) indy = fields.Nested( V20CredExRecordIndySchema, required=False, ) ld_proof = fields.Nested( V20CredExRecordLDProofSchema, required=False, )
[docs]class V20CredExRecordListResultSchema(OpenAPISchema): """Result schema for credential exchange record list query.""" results = fields.List( fields.Nested(V20CredExRecordDetailSchema), description="Credential exchange records and corresponding detail records", )
[docs]class V20CredStoreRequestSchema(OpenAPISchema): """Request schema for sending a credential store admin message.""" credential_id = fields.Str(required=False)
[docs]class V20CredFilterIndySchema(OpenAPISchema): """Indy credential filtration criteria.""" cred_def_id = fields.Str( description="Credential definition identifier", required=False, **INDY_CRED_DEF_ID, ) schema_id = fields.Str( description="Schema identifier", required=False, **INDY_SCHEMA_ID ) schema_issuer_did = fields.Str( description="Schema issuer DID", required=False, **INDY_DID ) schema_name = fields.Str( description="Schema name", required=False, example="preferences" ) schema_version = fields.Str( description="Schema version", required=False, **INDY_VERSION ) issuer_did = fields.Str( description="Credential issuer DID", required=False, **INDY_DID )
[docs]class V20CredFilterSchema(OpenAPISchema): """Credential filtration criteria.""" indy = fields.Nested( V20CredFilterIndySchema, required=False, description="Credential filter for indy", ) ld_proof = fields.Nested( LDProofVCDetailSchema, required=False, description="Credential filter for linked data proof", ) @validates_schema def validate_fields(self, data, **kwargs): """ Validate schema fields. Data must have indy, ld_proof, or both. Args: data: The data to validate Raises: ValidationError: if data has neither indy nor ld_proof """ if not any(f.api in data for f in V20CredFormat.Format): raise ValidationError( "V20CredFilterSchema requires indy, ld_proof, or both" )
[docs]class V20IssueCredSchemaCore(AdminAPIMessageTracingSchema): """Filter, auto-remove, comment, trace.""" filter_ = fields.Nested( V20CredFilterSchema, required=True, data_key="filter", description="Credential specification criteria by format", ) auto_remove = fields.Bool( description=( "Whether to remove the credential exchange record on completion " "(overrides --preserve-exchange-records configuration setting)" ), required=False, ) comment = fields.Str( description="Human-readable comment", required=False, allow_none=True ) credential_preview = fields.Nested(V20CredPreviewSchema, required=False) @validates_schema def validate(self, data, **kwargs): """Make sure preview is present when indy format is present.""" if data.get("filter", {}).get("indy") and not data.get("credential_preview"): raise ValidationError( "Credential preview is required if indy filter is present" )
[docs]class V20CredFilterLDProofSchema(OpenAPISchema): """Credential filtration criteria.""" ld_proof = fields.Nested( LDProofVCDetailSchema, required=True, description="Credential filter for linked data proof", )
[docs]class V20CredRequestFreeSchema(AdminAPIMessageTracingSchema): """Filter, auto-remove, comment, trace.""" connection_id = fields.UUID( description="Connection identifier", required=True, example=UUIDFour.EXAMPLE, # typically but not necessarily a UUID4 ) # Request can only start with LD Proof filter_ = fields.Nested( V20CredFilterLDProofSchema, required=True, data_key="filter", description="Credential specification criteria by format", ) auto_remove = fields.Bool( description=( "Whether to remove the credential exchange record on completion " "(overrides --preserve-exchange-records configuration setting)" ), required=False, ) comment = fields.Str( description="Human-readable comment", required=False, allow_none=True ) trace = fields.Bool( description="Whether to trace event (default false)", required=False, example=False, ) holder_did = fields.Str( description="Holder DID to substitute for the credentialSubject.id", required=False, allow_none=True, example="did:key:ahsdkjahsdkjhaskjdhakjshdkajhsdkjahs", )
[docs]class V20CredExFreeSchema(V20IssueCredSchemaCore): """Request schema for sending credential admin message.""" connection_id = fields.UUID( description="Connection identifier", required=True, example=UUIDFour.EXAMPLE, # typically but not necessarily a UUID4 ) verification_method = fields.Str( required=False, default=None, allow_none=True, description="For ld-proofs. Verification method for signing.", )
[docs]class V20CredBoundOfferRequestSchema(OpenAPISchema): """Request schema for sending bound credential offer admin message.""" filter_ = fields.Nested( V20CredFilterSchema, required=False, data_key="filter", description="Credential specification criteria by format", ) counter_preview = fields.Nested( V20CredPreviewSchema, required=False, description="Optional content for counter-proposal", ) @validates_schema def validate_fields(self, data, **kwargs): """Validate schema fields: need both filter and counter_preview or neither.""" if ( "filter_" in data and ("indy" in data["filter_"] or "ld_proof" in data["filter_"]) ) ^ ("counter_preview" in data): raise ValidationError( f"V20CredBoundOfferRequestSchema\n{data}\nrequires " "both indy/ld_proof filter and counter_preview or neither" )
[docs]class V20CredOfferRequestSchema(V20IssueCredSchemaCore): """Request schema for sending credential offer admin message.""" connection_id = fields.UUID( description="Connection identifier", required=True, example=UUIDFour.EXAMPLE, # typically but not necessarily a UUID4 ) auto_issue = fields.Bool( description=( "Whether to respond automatically to credential requests, creating " "and issuing requested credentials" ), required=False, )
[docs]class V20CredOfferConnFreeRequestSchema(V20IssueCredSchemaCore): """Request schema for creating credential offer free from connection.""" auto_issue = fields.Bool( description=( "Whether to respond automatically to credential requests, creating " "and issuing requested credentials" ), required=False, )
[docs]class V20CredRequestRequestSchema(OpenAPISchema): """Request schema for sending credential request message.""" holder_did = fields.Str( description="Holder DID to substitute for the credentialSubject.id", required=False, allow_none=True, example="did:key:ahsdkjahsdkjhaskjdhakjshdkajhsdkjahs", )
[docs]class V20CredIssueRequestSchema(OpenAPISchema): """Request schema for sending credential issue admin message.""" comment = fields.Str( description="Human-readable comment", required=False, allow_none=True )
[docs]class V20CredIssueProblemReportRequestSchema(OpenAPISchema): """Request schema for sending problem report.""" description = fields.Str(required=True)
[docs]class V20CredIdMatchInfoSchema(OpenAPISchema): """Path parameters and validators for request taking credential id.""" credential_id = fields.Str( description="Credential identifier", required=True, example=UUIDFour.EXAMPLE )
[docs]class V20CredExIdMatchInfoSchema(OpenAPISchema): """Path parameters and validators for request taking credential exchange id.""" cred_ex_id = fields.Str( description="Credential exchange identifier", required=True, **UUID4 )
def _formats_filters(filt_spec: Mapping) -> Mapping: """Break out formats and filters for v2.0 cred proposal messages.""" return ( { "formats": [ V20CredFormat( attach_id=fmt_api, format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][fmt_api], ) for fmt_api in filt_spec ], "filters_attach": [ AttachDecorator.data_base64(filt_by_fmt, ident=fmt_api) for (fmt_api, filt_by_fmt) in filt_spec.items() ], } if filt_spec else {} ) async def _get_attached_credentials( profile: Profile, cred_ex_record: V20CredExRecord ) -> Mapping: """Fetch the detail records attached to a credential exchange.""" result = {} for fmt in V20CredFormat.Format: detail_record = await fmt.handler(profile).get_detail_record( cred_ex_record.cred_ex_id ) if detail_record: result[fmt.api] = detail_record return result def _format_result_with_details( cred_ex_record: V20CredExRecord, details: Mapping ) -> Mapping: """Get credential exchange result with detail records.""" result = {"cred_ex_record": cred_ex_record.serialize()} for fmt in V20CredFormat.Format: ident = fmt.api detail_record = details.get(ident) result[ident] = detail_record.serialize() if detail_record else None return result @docs( tags=["issue-credential v2.0"], summary="Fetch all credential exchange records", ) @querystring_schema(V20CredExRecordListQueryStringSchema) @response_schema(V20CredExRecordListResultSchema(), 200, description="") async def credential_exchange_list(request: web.BaseRequest): """ Request handler for searching credential exchange records. Args: request: aiohttp request object Returns: The connection list response """ context: AdminRequestContext = request["context"] profile = context.profile tag_filter = {} if "thread_id" in request.query and request.query["thread_id"] != "": tag_filter["thread_id"] = request.query["thread_id"] post_filter = { k: request.query[k] for k in ("connection_id", "role", "state") if request.query.get(k, "") != "" } try: async with profile.session() as session: cred_ex_records = await V20CredExRecord.query( session=session, tag_filter=tag_filter, post_filter_positive=post_filter, ) results = [] for cxr in cred_ex_records: details = await _get_attached_credentials(profile, cxr) result = _format_result_with_details(cxr, details) results.append(result) except (StorageError, BaseModelError) as err: raise web.HTTPBadRequest(reason=err.roll_up) from err return web.json_response({"results": results}) @docs( tags=["issue-credential v2.0"], summary="Fetch a single credential exchange record", ) @match_info_schema(V20CredExIdMatchInfoSchema()) @response_schema(V20CredExRecordDetailSchema(), 200, description="") async def credential_exchange_retrieve(request: web.BaseRequest): """ Request handler for fetching single credential exchange record. Args: request: aiohttp request object Returns: The credential exchange record """ context: AdminRequestContext = request["context"] profile = context.profile outbound_handler = request["outbound_message_router"] cred_ex_id = request.match_info["cred_ex_id"] cred_ex_record = None try: async with profile.session() as session: cred_ex_record = await V20CredExRecord.retrieve_by_id(session, cred_ex_id) details = await _get_attached_credentials(profile, cred_ex_record) result = _format_result_with_details(cred_ex_record, details) except StorageNotFoundError as err: # no such cred ex record: not protocol error, user fat-fingered id raise web.HTTPNotFound(reason=err.roll_up) from err except (BaseModelError, StorageError) as err: # present but broken or hopeless: protocol error await report_problem( err, ProblemReportReason.ISSUANCE_ABANDONED.value, web.HTTPBadRequest, cred_ex_record, outbound_handler, ) return web.json_response(result) @docs( tags=["issue-credential v2.0"], summary=( "Create a credential record without " "sending (generally for use with Out-Of-Band)" ), ) @request_schema(V20IssueCredSchemaCore()) @response_schema(V20CredExRecordSchema(), 200, description="") async def credential_exchange_create(request: web.BaseRequest): """ Request handler for creating a credential from attr values. The internal credential record will be created without the credential being sent to any connection. This can be used in conjunction with the `oob` protocols to bind messages to an out of band message. Args: request: aiohttp request object Returns: The credential exchange record """ r_time = get_timer() context: AdminRequestContext = request["context"] body = await request.json() comment = body.get("comment") preview_spec = body.get("credential_preview") filt_spec = body.get("filter") auto_remove = body.get("auto_remove") if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") trace_msg = body.get("trace") try: # Not all formats use credential preview cred_preview = ( V20CredPreview.deserialize(preview_spec) if preview_spec else None ) cred_proposal = V20CredProposal( comment=comment, credential_preview=cred_preview, **_formats_filters(filt_spec), ) cred_proposal.assign_trace_decorator( context.settings, trace_msg, ) trace_event( context.settings, cred_proposal, outcome="credential_exchange_create.START", ) cred_manager = V20CredManager(context.profile) (cred_ex_record, cred_offer_message) = await cred_manager.prepare_send( connection_id=None, cred_proposal=cred_proposal, auto_remove=auto_remove, ) except (StorageError, BaseModelError) as err: raise web.HTTPBadRequest(reason=err.roll_up) from err trace_event( context.settings, cred_offer_message, outcome="credential_exchange_create.END", perf_counter=r_time, ) return web.json_response(cred_ex_record.serialize()) @docs( tags=["issue-credential v2.0"], summary="Send holder a credential, automating entire flow", ) @request_schema(V20CredExFreeSchema()) @response_schema(V20CredExRecordSchema(), 200, description="") async def credential_exchange_send(request: web.BaseRequest): """ Request handler for sending credential from issuer to holder from attr values. If both issuer and holder are configured for automatic responses, the operation ultimately results in credential issue; otherwise, the result waits on the first response not automated; the credential exchange record retains state regardless. Args: request: aiohttp request object Returns: The credential exchange record """ r_time = get_timer() context: AdminRequestContext = request["context"] profile = context.profile outbound_handler = request["outbound_message_router"] body = await request.json() comment = body.get("comment") connection_id = body.get("connection_id") verification_method = body.get("verification_method") filt_spec = body.get("filter") if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") preview_spec = body.get("credential_preview") auto_remove = body.get("auto_remove") trace_msg = body.get("trace") conn_record = None cred_ex_record = None try: # Not all formats use credential preview cred_preview = ( V20CredPreview.deserialize(preview_spec) if preview_spec else None ) async with profile.session() as session: conn_record = await ConnRecord.retrieve_by_id(session, connection_id) if not conn_record.is_ready: raise web.HTTPForbidden(reason=f"Connection {connection_id} not ready") # TODO: why do we create a proposal and then use that to create an offer. # Seems easier to just pass the proposal data to the format specific handler cred_proposal = V20CredProposal( comment=comment, credential_preview=cred_preview, **_formats_filters(filt_spec), ) cred_proposal.assign_trace_decorator( context.settings, trace_msg, ) trace_event( context.settings, cred_proposal, outcome="credential_exchange_send.START", ) cred_manager = V20CredManager(profile) (cred_ex_record, cred_offer_message) = await cred_manager.prepare_send( connection_id, verification_method=verification_method, cred_proposal=cred_proposal, auto_remove=auto_remove, ) result = cred_ex_record.serialize() except ( BaseModelError, LedgerError, StorageError, V20CredManagerError, V20CredFormatError, ) as err: LOGGER.exception("Error preparing credential offer") if cred_ex_record: async with profile.session() as session: await cred_ex_record.save_error_state(session, reason=err.roll_up) await report_problem( err, ProblemReportReason.ISSUANCE_ABANDONED.value, web.HTTPBadRequest, cred_ex_record or conn_record, outbound_handler, ) await outbound_handler( cred_offer_message, connection_id=cred_ex_record.connection_id, ) trace_event( context.settings, cred_offer_message, outcome="credential_exchange_send.END", perf_counter=r_time, ) return web.json_response(result) @docs( tags=["issue-credential v2.0"], summary="Send issuer a credential proposal", ) @request_schema(V20CredExFreeSchema()) @response_schema(V20CredExRecordSchema(), 200, description="") async def credential_exchange_send_proposal(request: web.BaseRequest): """ Request handler for sending credential proposal. Args: request: aiohttp request object Returns: The credential exchange record """ r_time = get_timer() context: AdminRequestContext = request["context"] profile = context.profile outbound_handler = request["outbound_message_router"] body = await request.json() connection_id = body.get("connection_id") comment = body.get("comment") preview_spec = body.get("credential_preview") filt_spec = body.get("filter") if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") auto_remove = body.get("auto_remove") trace_msg = body.get("trace") conn_record = None cred_ex_record = None try: cred_preview = ( V20CredPreview.deserialize(preview_spec) if preview_spec else None ) async with profile.session() as session: conn_record = await ConnRecord.retrieve_by_id(session, connection_id) if not conn_record.is_ready: raise web.HTTPForbidden(reason=f"Connection {connection_id} not ready") cred_manager = V20CredManager(profile) cred_ex_record = await cred_manager.create_proposal( connection_id=connection_id, auto_remove=auto_remove, comment=comment, cred_preview=cred_preview, trace=trace_msg, fmt2filter={ V20CredFormat.Format.get(fmt_api): filt_by_fmt for (fmt_api, filt_by_fmt) in filt_spec.items() }, ) cred_proposal_message = cred_ex_record.cred_proposal result = cred_ex_record.serialize() except (BaseModelError, StorageError) as err: LOGGER.exception("Error preparing credential proposal") if cred_ex_record: async with profile.session() as session: await cred_ex_record.save_error_state(session, reason=err.roll_up) await report_problem( err, ProblemReportReason.ISSUANCE_ABANDONED.value, web.HTTPBadRequest, cred_ex_record or conn_record, outbound_handler, ) await outbound_handler(cred_proposal_message, connection_id=connection_id) trace_event( context.settings, cred_proposal_message, outcome="credential_exchange_send_proposal.END", perf_counter=r_time, ) return web.json_response(result) async def _create_free_offer( profile: Profile, filt_spec: Mapping = None, connection_id: str = None, auto_issue: bool = False, auto_remove: bool = False, preview_spec: dict = None, comment: str = None, trace_msg: bool = None, ): """Create a credential offer and related exchange record.""" cred_preview = V20CredPreview.deserialize(preview_spec) if preview_spec else None cred_proposal = V20CredProposal( comment=comment, credential_preview=cred_preview, **_formats_filters(filt_spec), ) cred_proposal.assign_trace_decorator( profile.settings, trace_msg, ) cred_ex_record = V20CredExRecord( connection_id=connection_id, initiator=V20CredExRecord.INITIATOR_SELF, role=V20CredExRecord.ROLE_ISSUER, cred_proposal=cred_proposal.serialize(), auto_issue=auto_issue, auto_remove=auto_remove, trace=trace_msg, ) cred_manager = V20CredManager(profile) (cred_ex_record, cred_offer_message) = await cred_manager.create_offer( cred_ex_record, comment=comment, ) return (cred_ex_record, cred_offer_message) @docs( tags=["issue-credential v2.0"], summary="Create a credential offer, independent of any proposal or connection", ) @request_schema(V20CredOfferConnFreeRequestSchema()) @response_schema(V20CredExRecordSchema(), 200, description="") async def credential_exchange_create_free_offer(request: web.BaseRequest): """ Request handler for creating free credential offer. Unlike with `send-offer`, this credential exchange is not tied to a specific connection. It must be dispatched out-of-band by the controller. Args: request: aiohttp request object Returns: The credential exchange record """ r_time = get_timer() context: AdminRequestContext = request["context"] profile = context.profile body = await request.json() auto_issue = body.get( "auto_issue", context.settings.get("debug.auto_respond_credential_request") ) auto_remove = body.get("auto_remove") comment = body.get("comment") preview_spec = body.get("credential_preview") filt_spec = body.get("filter") if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") trace_msg = body.get("trace") cred_ex_record = None try: (cred_ex_record, cred_offer_message) = await _create_free_offer( profile=profile, filt_spec=filt_spec, auto_issue=auto_issue, auto_remove=auto_remove, preview_spec=preview_spec, comment=comment, trace_msg=trace_msg, ) result = cred_ex_record.serialize() except ( BaseModelError, LedgerError, V20CredFormatError, V20CredManagerError, ) as err: LOGGER.exception("Error creating free credential offer") if cred_ex_record: async with profile.session() as session: await cred_ex_record.save_error_state(session, reason=err.roll_up) raise web.HTTPBadRequest(reason=err.roll_up) trace_event( context.settings, cred_offer_message, outcome="credential_exchange_create_free_offer.END", perf_counter=r_time, ) return web.json_response(result) @docs( tags=["issue-credential v2.0"], summary="Send holder a credential offer, independent of any proposal", ) @request_schema(V20CredOfferRequestSchema()) @response_schema(V20CredExRecordSchema(), 200, description="") async def credential_exchange_send_free_offer(request: web.BaseRequest): """ Request handler for sending free credential offer. An issuer initiates a such a credential offer, free from any holder-initiated corresponding credential proposal with preview. Args: request: aiohttp request object Returns: The credential exchange record """ r_time = get_timer() context: AdminRequestContext = request["context"] profile = context.profile outbound_handler = request["outbound_message_router"] body = await request.json() connection_id = body.get("connection_id") filt_spec = body.get("filter") if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") auto_issue = body.get( "auto_issue", context.settings.get("debug.auto_respond_credential_request") ) auto_remove = body.get("auto_remove") comment = body.get("comment") preview_spec = body.get("credential_preview") trace_msg = body.get("trace") cred_ex_record = None conn_record = None try: async with profile.session() as session: conn_record = await ConnRecord.retrieve_by_id(session, connection_id) if not conn_record.is_ready: raise web.HTTPForbidden(reason=f"Connection {connection_id} not ready") cred_ex_record, cred_offer_message = await _create_free_offer( profile=profile, filt_spec=filt_spec, connection_id=connection_id, auto_issue=auto_issue, auto_remove=auto_remove, preview_spec=preview_spec, comment=comment, trace_msg=trace_msg, ) result = cred_ex_record.serialize() except ( BaseModelError, IndyIssuerError, LedgerError, StorageNotFoundError, V20CredFormatError, V20CredManagerError, ) as err: LOGGER.exception("Error preparing free credential offer") if cred_ex_record: async with profile.session() as session: await cred_ex_record.save_error_state(session, reason=err.roll_up) await report_problem( err, ProblemReportReason.ISSUANCE_ABANDONED.value, web.HTTPBadRequest, cred_ex_record or conn_record, outbound_handler, ) await outbound_handler(cred_offer_message, connection_id=connection_id) trace_event( context.settings, cred_offer_message, outcome="credential_exchange_send_free_offer.END", perf_counter=r_time, ) return web.json_response(result) @docs( tags=["issue-credential v2.0"], summary="Send holder a credential offer in reference to a proposal with preview", ) @match_info_schema(V20CredExIdMatchInfoSchema()) @request_schema(V20CredBoundOfferRequestSchema()) @response_schema(V20CredExRecordSchema(), 200, description="") async def credential_exchange_send_bound_offer(request: web.BaseRequest): """ Request handler for sending bound credential offer. A holder initiates this sequence with a credential proposal; this message responds with an offer bound to the proposal. Args: request: aiohttp request object Returns: The credential exchange record """ r_time = get_timer() context: AdminRequestContext = request["context"] profile = context.profile outbound_handler = request["outbound_message_router"] body = await request.json() if request.body_exists else {} filt_spec = body.get("filter") preview_spec = body.get("counter_preview") cred_ex_id = request.match_info["cred_ex_id"] cred_ex_record = None conn_record = None try: async with profile.session() as session: try: cred_ex_record = await V20CredExRecord.retrieve_by_id( session, cred_ex_id, ) except StorageNotFoundError as err: raise web.HTTPNotFound(reason=err.roll_up) from err connection_id = cred_ex_record.connection_id if cred_ex_record.state != ( V20CredExRecord.STATE_PROPOSAL_RECEIVED ): # check state here: manager call creates free offers too raise V20CredManagerError( f"Credential exchange record {cred_ex_record.cred_ex_id} " f"in {cred_ex_record.state} state " f"(must be {V20CredExRecord.STATE_PROPOSAL_RECEIVED})" ) conn_record = await ConnRecord.retrieve_by_id(session, connection_id) if not conn_record.is_ready: raise web.HTTPForbidden(reason=f"Connection {connection_id} not ready") cred_manager = V20CredManager(profile) (cred_ex_record, cred_offer_message) = await cred_manager.create_offer( cred_ex_record, counter_proposal=V20CredProposal( comment=None, credential_preview=V20CredPreview.deserialize(preview_spec), **_formats_filters(filt_spec), ) if preview_spec else None, comment=None, ) result = cred_ex_record.serialize() except ( BaseModelError, IndyIssuerError, LedgerError, StorageError, V20CredFormatError, V20CredManagerError, ) as err: LOGGER.exception("Error preparing bound credential offer") if cred_ex_record: async with profile.session() as session: await cred_ex_record.save_error_state(session, reason=err.roll_up) await report_problem( err, ProblemReportReason.ISSUANCE_ABANDONED.value, web.HTTPBadRequest, cred_ex_record, outbound_handler, ) except LinkedDataProofException as err: raise web.HTTPBadRequest(reason=err) from err await outbound_handler(cred_offer_message, connection_id=connection_id) trace_event( context.settings, cred_offer_message, outcome="credential_exchange_send_bound_offer.END", perf_counter=r_time, ) return web.json_response(result) @docs( tags=["issue-credential v2.0"], summary=( "Send issuer a credential request not bound to an existing thread." " Indy credentials cannot start at a request" ), ) @request_schema(V20CredRequestFreeSchema()) @response_schema(V20CredExRecordSchema(), 200, description="") async def credential_exchange_send_free_request(request: web.BaseRequest): """ Request handler for sending free credential request. Args: request: aiohttp request object Returns: The credential exchange record """ r_time = get_timer() context: AdminRequestContext = request["context"] profile = context.profile outbound_handler = request["outbound_message_router"] body = await request.json() connection_id = body.get("connection_id") comment = body.get("comment") filt_spec = body.get("filter") if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") auto_remove = body.get("auto_remove") trace_msg = body.get("trace") holder_did = body.get("holder_did") conn_record = None cred_ex_record = None try: try: async with profile.session() as session: conn_record = await ConnRecord.retrieve_by_id(session, connection_id) if not conn_record.is_ready: raise web.HTTPForbidden(reason=f"Connection {connection_id} not ready") except StorageNotFoundError as err: raise web.HTTPBadRequest(reason=err.roll_up) from err cred_manager = V20CredManager(profile) cred_proposal = V20CredProposal( comment=comment, **_formats_filters(filt_spec), ) cred_ex_record = V20CredExRecord( connection_id=connection_id, auto_remove=auto_remove, cred_proposal=cred_proposal.serialize(), initiator=V20CredExRecord.INITIATOR_SELF, role=V20CredExRecord.ROLE_HOLDER, trace=trace_msg, ) cred_ex_record, cred_request_message = await cred_manager.create_request( cred_ex_record=cred_ex_record, holder_did=holder_did, comment=comment, ) result = cred_ex_record.serialize() except ( BaseModelError, IndyHolderError, LedgerError, StorageError, V20CredManagerError, ) as err: LOGGER.exception("Error preparing free credential request") if cred_ex_record: async with profile.session() as session: await cred_ex_record.save_error_state(session, reason=err.roll_up) await report_problem( err, ProblemReportReason.ISSUANCE_ABANDONED.value, web.HTTPBadRequest, cred_ex_record, outbound_handler, ) await outbound_handler(cred_request_message, connection_id=connection_id) trace_event( context.settings, cred_request_message, outcome="credential_exchange_send_free_request.END", perf_counter=r_time, ) return web.json_response(result) @docs( tags=["issue-credential v2.0"], summary="Send issuer a credential request", ) @match_info_schema(V20CredExIdMatchInfoSchema()) @request_schema(V20CredRequestRequestSchema()) @response_schema(V20CredExRecordSchema(), 200, description="") async def credential_exchange_send_bound_request(request: web.BaseRequest): """ Request handler for sending credential request. Args: request: aiohttp request object Returns: The credential exchange record """ r_time = get_timer() context: AdminRequestContext = request["context"] profile = context.profile outbound_handler = request["outbound_message_router"] try: body = await request.json() or {} holder_did = body.get("holder_did") except JSONDecodeError: holder_did = None cred_ex_id = request.match_info["cred_ex_id"] cred_ex_record = None conn_record = None try: async with profile.session() as session: try: cred_ex_record = await V20CredExRecord.retrieve_by_id( session, cred_ex_id, ) except StorageNotFoundError as err: raise web.HTTPNotFound(reason=err.roll_up) from err conn_record = None if cred_ex_record.connection_id: try: conn_record = await ConnRecord.retrieve_by_id( session, cred_ex_record.connection_id ) except StorageNotFoundError as err: raise web.HTTPBadRequest(reason=err.roll_up) from err if conn_record and not conn_record.is_ready: raise web.HTTPForbidden( reason=f"Connection {cred_ex_record.connection_id} not ready" ) if conn_record or holder_did: holder_did = holder_did or conn_record.my_did else: # Need to get the holder DID from the out of band record async with profile.session() as session: oob_record = await OobRecord.retrieve_by_tag_filter( session, {"invi_msg_id": cred_ex_record.cred_offer._thread.pthid}, ) # Transform recipient key into did holder_did = default_did_from_verkey(oob_record.our_recipient_key) cred_manager = V20CredManager(profile) cred_ex_record, cred_request_message = await cred_manager.create_request( cred_ex_record, holder_did, ) result = cred_ex_record.serialize() except ( BaseModelError, IndyHolderError, LedgerError, StorageError, V20CredFormatError, V20CredManagerError, ) as err: LOGGER.exception("Error preparing bound credential request") if cred_ex_record: async with profile.session() as session: await cred_ex_record.save_error_state(session, reason=err.roll_up) await report_problem( err, ProblemReportReason.ISSUANCE_ABANDONED.value, web.HTTPBadRequest, cred_ex_record, outbound_handler, ) await outbound_handler( cred_request_message, connection_id=cred_ex_record.connection_id ) trace_event( context.settings, cred_request_message, outcome="credential_exchange_send_bound_request.END", perf_counter=r_time, ) return web.json_response(result) @docs( tags=["issue-credential v2.0"], summary="Send holder a credential", ) @match_info_schema(V20CredExIdMatchInfoSchema()) @request_schema(V20CredIssueRequestSchema()) @response_schema(V20CredExRecordDetailSchema(), 200, description="") async def credential_exchange_issue(request: web.BaseRequest): """ Request handler for sending credential. Args: request: aiohttp request object Returns: The credential exchange record """ r_time = get_timer() context: AdminRequestContext = request["context"] profile = context.profile outbound_handler = request["outbound_message_router"] body = await request.json() comment = body.get("comment") cred_ex_id = request.match_info["cred_ex_id"] cred_ex_record = None conn_record = None try: async with profile.session() as session: try: cred_ex_record = await V20CredExRecord.retrieve_by_id( session, cred_ex_id, ) except StorageNotFoundError as err: raise web.HTTPNotFound(reason=err.roll_up) from err conn_record = None if cred_ex_record.connection_id: conn_record = await ConnRecord.retrieve_by_id( session, cred_ex_record.connection_id ) if conn_record and not conn_record.is_ready: raise web.HTTPForbidden( reason=f"Connection {cred_ex_record.connection_id} not ready" ) cred_manager = V20CredManager(profile) (cred_ex_record, cred_issue_message) = await cred_manager.issue_credential( cred_ex_record, comment=comment, ) details = await _get_attached_credentials(profile, cred_ex_record) result = _format_result_with_details(cred_ex_record, details) except ( BaseModelError, IndyIssuerError, LedgerError, StorageError, V20CredFormatError, V20CredManagerError, ) as err: LOGGER.exception("Error preparing issued credential") if cred_ex_record: async with profile.session() as session: await cred_ex_record.save_error_state(session, reason=err.roll_up) await report_problem( err, ProblemReportReason.ISSUANCE_ABANDONED.value, web.HTTPBadRequest, cred_ex_record, outbound_handler, ) await outbound_handler( cred_issue_message, connection_id=cred_ex_record.connection_id ) trace_event( context.settings, cred_issue_message, outcome="credential_exchange_issue.END", perf_counter=r_time, ) return web.json_response(result) @docs( tags=["issue-credential v2.0"], summary="Store a received credential", ) @match_info_schema(V20CredExIdMatchInfoSchema()) @request_schema(V20CredStoreRequestSchema()) @response_schema(V20CredExRecordDetailSchema(), 200, description="") async def credential_exchange_store(request: web.BaseRequest): """ Request handler for storing credential. Args: request: aiohttp request object Returns: The credential exchange record """ r_time = get_timer() context: AdminRequestContext = request["context"] profile = context.profile outbound_handler = request["outbound_message_router"] try: body = await request.json() or {} cred_id = body.get("credential_id") except JSONDecodeError: cred_id = None cred_ex_id = request.match_info["cred_ex_id"] cred_ex_record = None conn_record = None try: async with profile.session() as session: try: cred_ex_record = await V20CredExRecord.retrieve_by_id( session, cred_ex_id, ) except StorageNotFoundError as err: raise web.HTTPNotFound(reason=err.roll_up) from err conn_record = None if cred_ex_record.connection_id: conn_record = await ConnRecord.retrieve_by_id( session, cred_ex_record.connection_id ) if conn_record and not conn_record.is_ready: raise web.HTTPForbidden( reason=f"Connection {cred_ex_record.connection_id} not ready" ) cred_manager = V20CredManager(profile) cred_ex_record = await cred_manager.store_credential(cred_ex_record, cred_id) except ( IndyHolderError, StorageError, V20CredManagerError, ) as err: # treat failure to store as mangled on receipt hence protocol error LOGGER.exception("Error storing issued credential") if cred_ex_record: async with profile.session() as session: await cred_ex_record.save_error_state(session, reason=err.roll_up) await report_problem( err, ProblemReportReason.ISSUANCE_ABANDONED.value, web.HTTPBadRequest, cred_ex_record, outbound_handler, ) try: # fetch these early, before potential removal details = await _get_attached_credentials(profile, cred_ex_record) # the record may be auto-removed here ( cred_ex_record, cred_ack_message, ) = await cred_manager.send_cred_ack(cred_ex_record) result = _format_result_with_details(cred_ex_record, details) except ( BaseModelError, StorageError, V20CredFormatError, V20CredManagerError, ) as err: # protocol finished OK: do not send problem report nor set record state error raise web.HTTPBadRequest(reason=err.roll_up) from err trace_event( context.settings, cred_ack_message, outcome="credential_exchange_store.END", perf_counter=r_time, ) return web.json_response(result) @docs( tags=["issue-credential v2.0"], summary="Remove an existing credential exchange record", ) @match_info_schema(V20CredExIdMatchInfoSchema()) @response_schema(V20IssueCredentialModuleResponseSchema(), 200, description="") async def credential_exchange_remove(request: web.BaseRequest): """ Request handler for removing a credential exchange record. Args: request: aiohttp request object """ context: AdminRequestContext = request["context"] cred_ex_id = request.match_info["cred_ex_id"] try: cred_manager = V20CredManager(context.profile) await cred_manager.delete_cred_ex_record(cred_ex_id) except StorageNotFoundError as err: # not a protocol error raise web.HTTPNotFound(reason=err.roll_up) from err except StorageError as err: # not a protocol error raise web.HTTPBadRequest(reason=err.roll_up) from err return web.json_response({}) @docs( tags=["issue-credential v2.0"], summary="Send a problem report for credential exchange", ) @match_info_schema(V20CredExIdMatchInfoSchema()) @request_schema(V20CredIssueProblemReportRequestSchema()) @response_schema(V20IssueCredentialModuleResponseSchema(), 200, description="") async def credential_exchange_problem_report(request: web.BaseRequest): """ Request handler for sending problem report. Args: request: aiohttp request object """ context: AdminRequestContext = request["context"] profile = context.profile outbound_handler = request["outbound_message_router"] cred_ex_id = request.match_info["cred_ex_id"] body = await request.json() description = body["description"] try: async with profile.session() as session: cred_ex_record = await V20CredExRecord.retrieve_by_id(session, cred_ex_id) report = problem_report_for_record(cred_ex_record, description) await cred_ex_record.save_error_state( session, reason=f"created problem report: {description}", ) except StorageNotFoundError as err: # other party does not care about meta-problems raise web.HTTPNotFound(reason=err.roll_up) from err except StorageError as err: raise web.HTTPBadRequest(reason=err.roll_up) from err await outbound_handler(report, connection_id=cred_ex_record.connection_id) return web.json_response({})
[docs]async def register(app: web.Application): """Register routes.""" app.add_routes( [ web.get( "/issue-credential-2.0/records", credential_exchange_list, allow_head=False, ), web.post( "/issue-credential-2.0/create-offer", credential_exchange_create_free_offer, ), web.get( "/issue-credential-2.0/records/{cred_ex_id}", credential_exchange_retrieve, allow_head=False, ), web.post("/issue-credential-2.0/create", credential_exchange_create), web.post("/issue-credential-2.0/send", credential_exchange_send), web.post( "/issue-credential-2.0/send-proposal", credential_exchange_send_proposal ), web.post( "/issue-credential-2.0/send-offer", credential_exchange_send_free_offer ), web.post( "/issue-credential-2.0/send-request", credential_exchange_send_free_request, ), web.post( "/issue-credential-2.0/records/{cred_ex_id}/send-offer", credential_exchange_send_bound_offer, ), web.post( "/issue-credential-2.0/records/{cred_ex_id}/send-request", credential_exchange_send_bound_request, ), web.post( "/issue-credential-2.0/records/{cred_ex_id}/issue", credential_exchange_issue, ), web.post( "/issue-credential-2.0/records/{cred_ex_id}/store", credential_exchange_store, ), web.post( "/issue-credential-2.0/records/{cred_ex_id}/problem-report", credential_exchange_problem_report, ), web.delete( "/issue-credential-2.0/records/{cred_ex_id}", credential_exchange_remove, ), ] )
[docs]def post_process_routes(app: web.Application): """Amend swagger API.""" # Add top-level tags description if "tags" not in app._state["swagger_dict"]: app._state["swagger_dict"]["tags"] = [] app._state["swagger_dict"]["tags"].append( { "name": "issue-credential v2.0", "description": "Credential issue v2.0", "externalDocs": {"description": "Specification", "url": SPEC_URI}, } )