diff --git a/acapy_agent/protocols/present_proof/v2_0/models/pres_exchange.py b/acapy_agent/protocols/present_proof/v2_0/models/pres_exchange.py index 3774dd70e8..b2b1fc873f 100644 --- a/acapy_agent/protocols/present_proof/v2_0/models/pres_exchange.py +++ b/acapy_agent/protocols/present_proof/v2_0/models/pres_exchange.py @@ -206,6 +206,11 @@ async def emit_event(self, session: ProfileSession, payload: Optional[Any] = Non if not session.profile.settings.get("debug.webhooks"): payload = V20PresExRecordWebhook(**payload) payload = payload.__dict__ + elif by_format := payload.get("by_format"): + # Issue #3802: remove legacy fields only when represented in by_format + for key in ("pres_proposal", "pres_request", "pres"): + if key in by_format: + payload.pop(key, None) await session.emit_event(topic, payload) diff --git a/acapy_agent/protocols/present_proof/v2_0/models/tests/test_record.py b/acapy_agent/protocols/present_proof/v2_0/models/tests/test_record.py index 9890ba6cb3..5b12143c01 100644 --- a/acapy_agent/protocols/present_proof/v2_0/models/tests/test_record.py +++ b/acapy_agent/protocols/present_proof/v2_0/models/tests/test_record.py @@ -4,9 +4,11 @@ from ......messaging.models.base_record import BaseExchangeRecord, BaseExchangeSchema from ......tests import mock from ......utils.testing import create_test_profile -from ...message_types import ATTACHMENT_FORMAT, PRES_20_PROPOSAL +from ...message_types import ATTACHMENT_FORMAT, PRES_20, PRES_20_PROPOSAL, PRES_20_REQUEST +from ...messages.pres import V20Pres from ...messages.pres_format import V20PresFormat from ...messages.pres_proposal import V20PresProposal +from ...messages.pres_request import V20PresRequest from .. import pres_exchange as test_module from ..pres_exchange import V20PresExRecord @@ -52,6 +54,16 @@ } }, } +INDY_PROOF = { + "proof": {"proofs": []}, + "requested_proof": { + "revealed_attrs": {}, + "self_attested_attrs": {}, + "unrevealed_attrs": {}, + "predicates": {}, + }, + "identifiers": [], +} class BasexRecordImpl(BaseExchangeRecord): @@ -134,3 +146,71 @@ async def test_save_error_state(self): mock_save.side_effect = test_module.StorageError() await record.save_error_state(session, reason="testing") mock_log_exc.assert_called_once() + + # BUG #3802: ensure webhook payloads omit legacy pres_* fields when by_format exists + async def test_emit_event_strips_legacy_pres_fields(self): + settings = { + "wallet.type": "askar", + "auto_provision": True, + "wallet.key": "5BngFuBpS4wjFfVFCtPqoix3ZXG2XR8XJ7qosUzMak7R", + "wallet.key_derivation_method": "RAW", + "debug.webhooks": True, + } + self.profile = await create_test_profile(settings=settings) + pres_request = V20PresRequest( + formats=[ + V20PresFormat( + attach_id="indy", + format_=ATTACHMENT_FORMAT[PRES_20_REQUEST][ + V20PresFormat.Format.INDY.api + ], + ) + ], + request_presentations_attach=[ + AttachDecorator.data_base64(mapping=INDY_PROOF_REQ, ident="indy") + ], + ) + pres_proposal = V20PresProposal( + formats=[ + V20PresFormat( + attach_id="indy", + format_=ATTACHMENT_FORMAT[PRES_20_PROPOSAL][ + V20PresFormat.Format.INDY.api + ], + ) + ], + proposals_attach=[ + AttachDecorator.data_base64(mapping=INDY_PROOF_REQ, ident="indy") + ], + ) + pres = V20Pres( + formats=[ + V20PresFormat( + attach_id="indy", + format_=ATTACHMENT_FORMAT[PRES_20][V20PresFormat.Format.INDY.api], + ) + ], + presentations_attach=[ + AttachDecorator.data_base64(mapping=INDY_PROOF, ident="indy") + ], + ) + record = V20PresExRecord( + pres_ex_id="pxid", + thread_id="thid", + connection_id="conn_id", + initiator="init", + role="role", + state=V20PresExRecord.STATE_PRESENTATION_RECEIVED, + pres_proposal=pres_proposal, + pres_request=pres_request, + pres=pres, + ) + + async with self.profile.session() as session: + session.emit_event = mock.CoroutineMock() + await record.emit_event(session) + + payload = session.emit_event.call_args.args[1] + assert "by_format" in payload + for key in ("pres", "pres_proposal", "pres_request"): + assert key not in payload and key in payload["by_format"] diff --git a/scenarios/examples/did_indy_issuance_and_revocation/example.py b/scenarios/examples/did_indy_issuance_and_revocation/example.py index 77b186e41c..358f537d51 100644 --- a/scenarios/examples/did_indy_issuance_and_revocation/example.py +++ b/scenarios/examples/did_indy_issuance_and_revocation/example.py @@ -18,10 +18,10 @@ didexchange, indy_anoncred_credential_artifacts, indy_issue_credential_v2, - indy_present_proof_v2, params, ) from aiohttp import ClientSession +from examples.util import _presentation_request_payload, indy_present_proof_v2 ALICE = getenv("ALICE", "http://alice:3001") BOB = getenv("BOB", "http://bob:3001") @@ -29,12 +29,12 @@ def summary(presentation: V20PresExRecord) -> str: """Summarize a presentation exchange record.""" - request = presentation.pres_request + request = _presentation_request_payload(presentation) return "Summary: " + json.dumps( { "state": presentation.state, "verified": presentation.verified, - "presentation_request": request.dict(by_alias=True) if request else None, + "presentation_request": request, }, indent=2, sort_keys=True, diff --git a/scenarios/examples/json_ld/example.py b/scenarios/examples/json_ld/example.py index 1629335fc0..eda55fc7da 100644 --- a/scenarios/examples/json_ld/example.py +++ b/scenarios/examples/json_ld/example.py @@ -10,15 +10,16 @@ from uuid import uuid4 from acapy_controller import Controller +from acapy_controller.controller import ControllerError from acapy_controller.logging import logging_to_stdout, pause_for_input, section from acapy_controller.models import DIDResult, V20PresExRecord from acapy_controller.protocols import ( didexchange, jsonld_issue_credential, - jsonld_present_proof, params, ) from aiohttp import ClientSession +from examples.util import jsonld_present_proof_v2 ALICE = getenv("ALICE", "http://alice:3001") BOB = getenv("BOB", "http://bob:3001") @@ -159,7 +160,7 @@ async def main(): pause_for_input() with section("Present example ED25519 credential"): - alice_pres_ex, bob_pres_ex = await jsonld_present_proof( + alice_pres_ex, bob_pres_ex = await jsonld_present_proof_v2( alice, bob, alice_conn.connection_id, @@ -207,7 +208,7 @@ async def main(): domain="test-degree", ) with section("Presentation summary", character="-"): - print(presentation_summary(alice_pres_ex.into(V20PresExRecord))) + print(presentation_summary(alice_pres_ex)) pause_for_input() @@ -241,7 +242,7 @@ async def main(): pause_for_input() with section("Present example P256 credential"): - alice_pres_ex, bob_pres_ex = await jsonld_present_proof( + alice_pres_ex, bob_pres_ex = await jsonld_present_proof_v2( alice, bob, alice_conn.connection_id, @@ -294,7 +295,7 @@ async def main(): domain="test-degree", ) with section("Presentation summary", character="-"): - print(presentation_summary(alice_pres_ex.into(V20PresExRecord))) + print(presentation_summary(alice_pres_ex)) pause_for_input() @@ -327,7 +328,7 @@ async def main(): pause_for_input() with section("Present ED25519 quick context credential"): - alice_pres_ex, bob_pres_ex = await jsonld_present_proof( + alice_pres_ex, bob_pres_ex = await jsonld_present_proof_v2( alice, bob, alice_conn.connection_id, @@ -370,42 +371,46 @@ async def main(): domain="test-degree", ) with section("Presentation summary", character="-"): - print(presentation_summary(alice_pres_ex.into(V20PresExRecord))) + print(presentation_summary(alice_pres_ex)) pause_for_input() with section("Issue BBS+ Credential"): - issuer_cred_ex, holder_cred_ex = await jsonld_issue_credential( - alice, - bob, - alice_conn.connection_id, - bob_conn.connection_id, - credential={ - "@context": [ - "https://www.w3.org/2018/credentials/v1", - { - "ex": "https://example.com/examples#", - "Employment": "ex:Employment", - "dateHired": "ex:dateHired", - "clearance": "ex:clearance", + try: + issuer_cred_ex, holder_cred_ex = await jsonld_issue_credential( + alice, + bob, + alice_conn.connection_id, + bob_conn.connection_id, + credential={ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + { + "ex": "https://example.com/examples#", + "Employment": "ex:Employment", + "dateHired": "ex:dateHired", + "clearance": "ex:clearance", + }, + ], + "type": ["VerifiableCredential", "Employment"], + "issuer": bls_alice_did, + "issuanceDate": str(date.today()), + "credentialSubject": { + "id": bls_bob_did, + "dateHired": str(date.today()), + "clearance": 1, }, - ], - "type": ["VerifiableCredential", "Employment"], - "issuer": bls_alice_did, - "issuanceDate": str(date.today()), - "credentialSubject": { - "id": bls_bob_did, - "dateHired": str(date.today()), - "clearance": 1, }, - }, - options={"proofType": "BbsBlsSignature2020"}, - ) + options={"proofType": "BbsBlsSignature2020"}, + ) + except ControllerError as err: + print(f"Skipping BBS+ flow due to runtime capability/error: {err}") + return pause_for_input() with section("Present BBS+ Credential with SD"): - alice_pres_ex, bob_pres_ex = await jsonld_present_proof( + alice_pres_ex, bob_pres_ex = await jsonld_present_proof_v2( alice, bob, alice_conn.connection_id, @@ -447,7 +452,7 @@ async def main(): domain="building-access", ) with section("Presentation summary", character="-"): - print(presentation_summary(alice_pres_ex.into(V20PresExRecord))) + print(presentation_summary(alice_pres_ex)) if __name__ == "__main__": diff --git a/scenarios/examples/kanon_issuance_and_presentation/example.py b/scenarios/examples/kanon_issuance_and_presentation/example.py index 5ec12591d1..92b47d0d18 100644 --- a/scenarios/examples/kanon_issuance_and_presentation/example.py +++ b/scenarios/examples/kanon_issuance_and_presentation/example.py @@ -21,6 +21,7 @@ from examples.util import ( CredDefResultAnonCreds, SchemaResultAnonCreds, + _presentation_request_payload, anoncreds_issue_credential_v2, anoncreds_present_proof_v2, ) @@ -31,12 +32,12 @@ def summary(presentation: V20PresExRecord) -> str: """Summarize a presentation exchange record.""" - request = presentation.pres_request + request = _presentation_request_payload(presentation) return "Summary: " + json.dumps( { "state": presentation.state, "verified": presentation.verified, - "presentation_request": request.dict(by_alias=True) if request else None, + "presentation_request": request, }, indent=2, sort_keys=True, diff --git a/scenarios/examples/multitenancy/example.py b/scenarios/examples/multitenancy/example.py index 4844ec85e5..9f986974fa 100644 --- a/scenarios/examples/multitenancy/example.py +++ b/scenarios/examples/multitenancy/example.py @@ -14,10 +14,10 @@ didexchange, indy_anoncred_credential_artifacts, indy_issue_credential_v2, - indy_present_proof_v2, params, ) from aiohttp import ClientSession +from examples.util import indy_present_proof_v2 AGENCY = getenv("AGENCY", "http://agency:3001") diff --git a/scenarios/examples/presenting_revoked_credential/example.py b/scenarios/examples/presenting_revoked_credential/example.py index 955bf6a07a..c1c2e70320 100644 --- a/scenarios/examples/presenting_revoked_credential/example.py +++ b/scenarios/examples/presenting_revoked_credential/example.py @@ -18,10 +18,10 @@ didexchange, indy_anoncred_credential_artifacts, indy_issue_credential_v2, - indy_present_proof_v2, params, ) from aiohttp import ClientSession +from examples.util import _presentation_request_payload, indy_present_proof_v2 ALICE = getenv("ALICE", "http://alice:3001") BOB = getenv("BOB", "http://bob:3001") @@ -29,12 +29,12 @@ def summary(presentation: V20PresExRecord) -> str: """Summarize a presentation exchange record.""" - request = presentation.pres_request + request = _presentation_request_payload(presentation) return "Summary: " + json.dumps( { "state": presentation.state, "verified": presentation.verified, - "presentation_request": request.dict(by_alias=True) if request else None, + "presentation_request": request, }, indent=2, sort_keys=True, diff --git a/scenarios/examples/simple_restart/example.py b/scenarios/examples/simple_restart/example.py index 76b4502704..0c0fc2a856 100644 --- a/scenarios/examples/simple_restart/example.py +++ b/scenarios/examples/simple_restart/example.py @@ -14,9 +14,8 @@ indy_anoncred_credential_artifacts, indy_anoncred_onboard, indy_issue_credential_v2, - indy_present_proof_v2, ) -from examples.util import wait_until_healthy +from examples.util import indy_present_proof_v2, wait_until_healthy import docker diff --git a/scenarios/examples/util.py b/scenarios/examples/util.py index 5ecbe72c7b..83ebc195ae 100644 --- a/scenarios/examples/util.py +++ b/scenarios/examples/util.py @@ -1,3 +1,5 @@ +"""Scenario helpers for ACA-Py examples.""" + import json import time from dataclasses import dataclass @@ -41,6 +43,7 @@ def wait_until_healthy(client, container_id: str, attempts: int = 350, is_health def update_wallet_type(agent_command: List, wallet_type: str) -> str: + """Update the wallet type argument in a CLI command list.""" for i in range(len(agent_command) - 1): if agent_command[i] == "--wallet-type": agent_command[i + 1] = wallet_type @@ -49,23 +52,37 @@ def update_wallet_type(agent_command: List, wallet_type: str) -> str: def get_wallet_name(agent_command: List) -> str: + """Return the wallet name argument from a CLI command list.""" for i in range(len(agent_command) - 1): if agent_command[i] == "--wallet-name": return agent_command[i + 1] raise Exception("Error unable to upgrade wallet type to askar-anoncreds") +def _presentation_request_payload( + presentation: V20PresExRecord, +) -> Optional[Dict[str, Any]]: + if presentation.by_format and presentation.by_format.pres_request: + return presentation.by_format.pres_request + request = presentation.pres_request + if not request: + return None + if isinstance(request, dict): + return request + if hasattr(request, "model_dump"): + return request.model_dump(by_alias=True) + return request.dict(by_alias=True) + + # anoncreds utilities: def anoncreds_presentation_summary(presentation: V20PresExRecord) -> str: """Summarize a presentation exchange record.""" - request = presentation.pres_request + request = _presentation_request_payload(presentation) return "Summary: " + json.dumps( { "state": presentation.state, "verified": presentation.verified, - "presentation_request": ( - request.model_dump(by_alias=True) if request else None - ), + "presentation_request": request, }, indent=2, sort_keys=True, @@ -191,6 +208,191 @@ def auto_select_credentials_for_presentation_request( ) +async def indy_present_proof_v2( + holder: Controller, + verifier: Controller, + holder_connection_id: str, + verifier_connection_id: str, + *, + name: Optional[str] = None, + version: Optional[str] = None, + comment: Optional[str] = None, + requested_attributes: Optional[List[Mapping[str, Any]]] = None, + requested_predicates: Optional[List[Mapping[str, Any]]] = None, + non_revoked: Optional[Mapping[str, int]] = None, + cred_rev_id: Optional[str] = None, +): + """Present a credential using present proof v2 (indy). + + This follows the acapy_controller.protocols flow, but resolves the holder-side + request payload via _presentation_request_payload(...) to support both legacy + pres_request and by_format webhook payload shapes. + """ + attrs = { + "name": name or "proof", + "version": version or "0.1.0", + "nonce": str(randbelow(10**10)), + "requested_attributes": { + str(uuid4()): attr for attr in requested_attributes or [] + }, + "requested_predicates": { + str(uuid4()): pred for pred in requested_predicates or [] + }, + "non_revoked": (non_revoked if non_revoked else None), + } + + verifier_pres_ex = await verifier.post( + "/present-proof-2.0/send-request", + json={ + "auto_verify": False, + "comment": comment or "Presentation request from minimal", + "connection_id": verifier_connection_id, + "presentation_request": {"indy": attrs}, + "trace": False, + }, + response=V20PresExRecord, + ) + verifier_pres_ex_id = verifier_pres_ex.pres_ex_id + + holder_pres_ex = await holder.event_with_values( + topic="present_proof_v2_0", + event_type=V20PresExRecord, + connection_id=holder_connection_id, + state="request-received", + ) + holder_pres_ex_id = holder_pres_ex.pres_ex_id + + relevant_creds = await holder.get( + f"/present-proof-2.0/records/{holder_pres_ex_id}/credentials", + response=List[CredPrecis], + ) + + if cred_rev_id: + relevant_creds = [ + cred + for cred in relevant_creds + if cred.cred_info._extra.get("cred_rev_id") == cred_rev_id + ] + + request_payload = _presentation_request_payload(holder_pres_ex) + assert request_payload + proof_request = request_payload + if "anoncreds" in request_payload or "indy" in request_payload: + proof_request = request_payload.get("indy") or request_payload.get("anoncreds") + assert proof_request + pres_spec = auto_select_credentials_for_presentation_request( + proof_request, relevant_creds + ) + await holder.post( + f"/present-proof-2.0/records/{holder_pres_ex_id}/send-presentation", + json={"indy": pres_spec.serialize()}, + response=V20PresExRecord, + ) + + await verifier.event_with_values( + topic="present_proof_v2_0", + event_type=V20PresExRecord, + pres_ex_id=verifier_pres_ex_id, + state="presentation-received", + ) + await verifier.post( + f"/present-proof-2.0/records/{verifier_pres_ex_id}/verify-presentation", + json={}, + response=V20PresExRecord, + ) + verifier_pres_ex = await verifier.event_with_values( + topic="present_proof_v2_0", + event_type=V20PresExRecord, + pres_ex_id=verifier_pres_ex_id, + state="done", + ) + + holder_pres_ex = await holder.event_with_values( + topic="present_proof_v2_0", + event_type=V20PresExRecord, + pres_ex_id=holder_pres_ex_id, + state="done", + ) + + return holder_pres_ex, verifier_pres_ex + + +async def jsonld_present_proof_v2( + holder: Controller, + verifier: Controller, + holder_connection_id: str, + verifier_connection_id: str, + *, + presentation_definition: Mapping[str, Any], + domain: Optional[str] = None, + challenge: Optional[str] = None, + comment: Optional[str] = None, +): + """Present a credential using present proof v2 (DIF/JSON-LD).""" + dif_options: Dict[str, Any] = {"challenge": challenge or str(uuid4())} + if domain: + dif_options["domain"] = domain + + verifier_pres_ex = await verifier.post( + "/present-proof-2.0/send-request", + json={ + "auto_verify": False, + "comment": comment or "Presentation request from minimal", + "connection_id": verifier_connection_id, + "presentation_request": { + "dif": { + "presentation_definition": dict(presentation_definition), + "options": dif_options, + } + }, + "trace": False, + }, + response=V20PresExRecord, + ) + verifier_pres_ex_id = verifier_pres_ex.pres_ex_id + + holder_pres_ex = await holder.event_with_values( + topic="present_proof_v2_0", + event_type=V20PresExRecord, + connection_id=holder_connection_id, + state="request-received", + ) + holder_pres_ex_id = holder_pres_ex.pres_ex_id + + await holder.post( + f"/present-proof-2.0/records/{holder_pres_ex_id}/send-presentation", + json={"dif": {}}, + response=V20PresExRecord, + ) + + await verifier.event_with_values( + topic="present_proof_v2_0", + event_type=V20PresExRecord, + pres_ex_id=verifier_pres_ex_id, + state="presentation-received", + ) + await verifier.post( + f"/present-proof-2.0/records/{verifier_pres_ex_id}/verify-presentation", + json={}, + response=V20PresExRecord, + ) + verifier_pres_ex = await verifier.event_with_values( + topic="present_proof_v2_0", + event_type=V20PresExRecord, + pres_ex_id=verifier_pres_ex_id, + state="done", + ) + + holder_pres_ex = await holder.event_with_values( + topic="present_proof_v2_0", + event_type=V20PresExRecord, + pres_ex_id=holder_pres_ex_id, + state="done", + ) + + return holder_pres_ex, verifier_pres_ex + + async def anoncreds_issue_credential_v2( issuer: Controller, holder: Controller, @@ -413,7 +615,6 @@ async def anoncreds_present_proof_v2( connection_id=holder_connection_id, state="request-received", ) - assert holder_pres_ex.pres_request holder_pres_ex_id = holder_pres_ex.pres_ex_id relevant_creds = await holder.get( @@ -429,10 +630,12 @@ async def anoncreds_present_proof_v2( if cred.cred_info._extra.get("cred_rev_id") == cred_rev_id ] - assert holder_pres_ex.by_format.pres_request - proof_request = holder_pres_ex.by_format.pres_request.get( - "anoncreds" - ) or holder_pres_ex.by_format.pres_request.get("indy") + request_payload = _presentation_request_payload(holder_pres_ex) + assert request_payload + proof_request = request_payload + if "anoncreds" in request_payload or "indy" in request_payload: + proof_request = request_payload.get("anoncreds") or request_payload.get("indy") + assert proof_request pres_spec = auto_select_credentials_for_presentation_request( proof_request, relevant_creds )