diff --git a/docs/maintainer_features.rst b/docs/maintainer_features.rst index 1210d97..017668c 100644 --- a/docs/maintainer_features.rst +++ b/docs/maintainer_features.rst @@ -92,7 +92,7 @@ Feature Inventory * - Credential requests - ``client.credentials()``, ``client.ipex()`` - ``signify.app.credentialing`` - - Maintained for query/export/issue plus grant/admit presentation workflows. + - Maintained for credential query/read/delete plus canonical issue/revoke workflows, with IPEX still limited to grant/admit presentation paths. * - Delegation requests - ``client.delegations()``, ``client.identifiers().create(..., delpre=...)`` - ``signify.app.delegating``, ``signify.app.aiding`` @@ -322,16 +322,105 @@ Routes: - ``POST /credentials/query`` - ``GET /credentials/{said}`` +- ``DELETE /credentials/{said}`` - ``POST /identifiers/{name}/credentials`` +- ``DELETE /identifiers/{name}/credentials/{said}`` +- ``POST /identifiers/{name}/ipex/apply`` +- ``POST /identifiers/{name}/ipex/offer`` +- ``POST /identifiers/{name}/ipex/agree`` - ``POST /identifiers/{name}/ipex/grant`` - ``POST /identifiers/{name}/ipex/admit`` Responsibilities: - Query locally held credentials. -- Export a credential in CESR JSON form. -- Construct and submit credential issuance events. -- Construct and submit IPEX grant and admit exchanges for presentation flows. +- Read a credential through one maintained JSON/CESR contract: + ``get(said, includeCESR=False)``, with ``export(said)`` kept as the CESR + compatibility alias. +- Delete one locally stored credential copy through ``delete(said)``. +- Construct and submit credential issuance events through canonical + ``issue(name, registryName, ...)`` while keeping the older + ``create(hab, registry, ...)`` form as explicit compatibility surface. +- Construct and submit credential revocation events through + ``revoke(name, said, *, timestamp=None)`` and expose the result through the + dedicated write-result wrapper. +- Construct and submit IPEX apply, offer, agree, grant, and admit exchanges + for credential conversation and presentation flows. + +Maintainer note: + +- Keep the early IPEX conversation verbs and the later presentation verbs in + one coherent resource surface; do not re-split them across unrelated modules. + +Maintained API contract: + +``Credentials`` + Canonical read methods: + + - ``list(filter=None, sort=None, skip=0, limit=25)`` + - ``get(said, includeCESR=False)`` + - ``delete(said)`` + - ``state(registry_said, credential_said)`` + + Canonical write methods: + + - ``issue(name, registryName, data, schema, *, recipient=None, edges=None, rules=None, private=False, timestamp=None)`` + - ``revoke(name, said, *, timestamp=None)`` + + Compatibility methods: + + - ``export(said)`` as a CESR read alias for ``get(..., includeCESR=True)`` + - ``create(hab, registry, ...)`` as the legacy issuance wrapper + - ``create_from_events(...)`` as the low-level replay surface + + Result wrappers: + + - ``CredentialIssueResult`` exposes ``acdc``, ``iss``, ``anc``, ``sigs``, ``response``, and ``op()`` + - ``CredentialRevokeResult`` exposes ``rev``, ``anc``, ``sigs``, ``response``, and ``op()`` + +``Ipex`` + Early conversation verbs: + + - ``apply(...)`` + - ``offer(...)`` + - ``agree(...)`` + + Presentation verbs: + + - ``grant(...)`` + - ``admit(...)`` + + Submit methods: + + - ``submitApply(...)`` + - ``submitOffer(...)`` + - ``submitAgree(...)`` + - ``submitGrant(...)`` + - ``submitAdmit(...)`` + + Builder methods return ``(exn, sigs, atc)``: + + - ``exn`` is the locally built peer exchange message + - ``sigs`` are signatures over ``exn`` + - ``atc`` is attachment material for embedded payloads and is usually empty + for ``apply``, ``agree``, and ``admit``, but often populated for + ``offer`` and especially ``grant`` + +Workflow notes: + +- Canonical single-sig credential flow: + issue with ``Credentials.issue(...)``, present with ``Ipex.grant(...)``, + acknowledge with ``Ipex.admit(...)``, then read the received credential back + through ``Credentials.get(...)`` or ``Credentials.list(...)``. +- Canonical full IPEX conversation flow: + ``apply -> offer -> agree -> grant -> admit``. +- Multisig IPEX note: + notifications are discovery signals only; the authoritative objects are the + stored exchange or credential records fetched after notification SAID + discovery. +- Multisig grant/admit note: + shared timestamps and grant-wave completion before admit starts are part of + the real workflow contract, not test-only ceremony. Primary tests: @@ -404,6 +493,9 @@ Routes: - ``POST /identifiers/{name}/exchanges`` - ``GET /exchanges/{said}`` +- ``POST /identifiers/{name}/ipex/apply`` +- ``POST /identifiers/{name}/ipex/offer`` +- ``POST /identifiers/{name}/ipex/agree`` - ``POST /identifiers/{name}/ipex/grant`` - ``POST /identifiers/{name}/ipex/admit`` @@ -413,8 +505,17 @@ Responsibilities: - Send prepared exchange messages to one or more recipients. - Retrieve exchange messages for inspection during multisig and credential workflows. -- Build the IPEX grant/admit messages layered on top of the peer exchange +- Build the full IPEX conversation layered on top of the peer exchange + transport: apply, offer, agree, grant, and admit. + +Maintainer note: + +- ``Exchanges`` owns generic peer-message transport. +- ``Ipex`` owns the credential-specific conversation built on top of that transport. +- When debugging multisig or IPEX flows, inspect the stored exchange payload + by SAID after a notification arrives instead of treating the notification row + itself as the durable source of truth. Primary tests: diff --git a/docs/signify_app.rst b/docs/signify_app.rst index 1819b00..1d8d0f6 100644 --- a/docs/signify_app.rst +++ b/docs/signify_app.rst @@ -34,6 +34,17 @@ signify.app.coring signify.app.credentialing ------------------------- +``signify.app.credentialing`` intentionally keeps three adjacent public +surfaces together: + +- ``Registries`` owns registry lifecycle and serialization helpers. +- ``Credentials`` owns stored credential reads plus issue/revoke operations. +- ``Ipex`` owns conversation and presentation exchange methods layered on top + of peer ``exn`` transport. + +Read the class and method docstrings in this section as the detailed reference +contract for that split. + .. automodule:: signify.app.credentialing :members: diff --git a/src/signify/app/credentialing.py b/src/signify/app/credentialing.py index 768135d..3580c4c 100644 --- a/src/signify/app/credentialing.py +++ b/src/signify/app/credentialing.py @@ -1,11 +1,23 @@ # -*- encoding: utf-8 -*- -"""Credential, registry, and IPEX workflow helpers for SignifyPy. +"""Credential registry, credential, and IPEX workflow helpers for SignifyPy. -This module covers three tightly related request families: +This module intentionally keeps three adjacent request families together +because real credential workflows cross their boundaries constantly: -- credential registry lifecycle -- credential issuance and export -- IPEX grant and admit message construction/submission +- :class:`Registries` owns registry read, create, rename, and serialization + helpers. +- :class:`Credentials` owns stored-credential reads plus issue and revoke + operations. +- :class:`Ipex` owns the peer ``exn`` conversation layered on top of those + credential artifacts: ``apply -> offer -> agree -> grant -> admit``. + +Rule of thumb: + +- use :class:`Credentials` when the operation is about stored credentials or + credential TEL state; +- use :class:`Ipex` when the operation is about exchanging credential-related + messages between participants; +- use :class:`Registries` when the operation is about the VDR registry itself. """ from collections import namedtuple @@ -23,7 +35,18 @@ class RegistryResult: - """Write-path wrapper for registry creation results.""" + """Canonical wrapper for registry creation results. + + Attributes: + regser: Local registry inception event serder. + serder: Local anchoring interaction serder submitted with the request. + sigs (list[str]): Signatures over ``serder`` from the local keeper. + response: Raw HTTP response object returned by the registry submission. + + The wrapper keeps both the locally built event material and the KERIA + response together so callers can inspect the exact payload they created and + still retrieve the operation JSON through :meth:`op`. + """ def __init__(self, regser, serder, sigs, response): self.regser = regser @@ -36,6 +59,73 @@ def op(self): return self.response.json() +class CredentialIssueResult: + """Canonical wrapper for credential issuance results. + + Attributes: + acdc: The issued credential serder. + iss: The TEL issuance event serder. + anc: The KEL anchoring interaction serder. + sigs (list[str]): Signatures over ``anc`` from the local keeper. + response: Raw HTTP response object returned by the issuance submission. + + ``CredentialIssueResult`` is the maintained return shape for + :meth:`Credentials.issue`. It remains iterable for transition safety so + older tuple-unpacking call sites can migrate gradually. + """ + + def __init__(self, acdc, iss, anc, sigs, response): + self.acdc = acdc + self.iss = iss + self.anc = anc + self.sigs = sigs + self.response = response + + def op(self): + """Return the decoded operation payload from the stored response.""" + return self.response.json() + + def __iter__(self): + """Yield the historical tuple shape for transition safety.""" + yield self.acdc + yield self.iss + yield self.anc + yield self.sigs + yield self.op() + + +class CredentialRevokeResult: + """Canonical wrapper for credential revocation results. + + Attributes: + rev: The TEL revocation event serder. + anc: The KEL anchoring interaction serder for the revoke event. + sigs (list[str]): Signatures over ``anc`` from the local keeper. + response: Raw HTTP response object returned by the revoke submission. + + ``CredentialRevokeResult`` is the maintained return shape for + :meth:`Credentials.revoke`. It remains iterable for transition safety so + older tuple-unpacking call sites can migrate gradually. + """ + + def __init__(self, rev, anc, sigs, response): + self.rev = rev + self.anc = anc + self.sigs = sigs + self.response = response + + def op(self): + """Return the decoded operation payload from the stored response.""" + return self.response.json() + + def __iter__(self): + """Yield the historical tuple shape for transition safety.""" + yield self.rev + yield self.anc + yield self.sigs + yield self.op() + + class Registries: """Resource wrapper for registry lifecycle operations under one identifier. @@ -57,18 +147,33 @@ def __init__(self, client: SignifyClient): """Create a registries resource bound to one Signify client. Parameters: - client (SignifyClient): Signify client used to access KERIA registry - endpoints. + client (SignifyClient): Signify client used to access KERIA + registry endpoints. """ self.client = client def get(self, name, registryName): - """Fetch one credential registry under an identifier alias.""" + """Fetch one registry record under an identifier alias. + + Parameters: + name (str): Identifier alias that owns the registry. + registryName (str): Registry alias under ``name``. + + Returns: + dict: Decoded registry record returned by KERIA. + """ res = self.client.get(f"/identifiers/{name}/registries/{registryName}") return res.json() def list(self, name): - """List credential registries under one identifier alias.""" + """List registries under one identifier alias. + + Parameters: + name (str): Identifier alias whose registries should be listed. + + Returns: + list[dict]: Decoded registry records returned by KERIA. + """ res = self.client.get(f"/identifiers/{name}/registries") return res.json() @@ -83,13 +188,31 @@ def create( toad=0, nonce=None, ): - """Create and submit a new credential registry inception request. + """Create and submit a new registry inception request. + + Maintained call shape: + ``create(name, registryName, *, noBackers=True, baks=None, toad=0, nonce=None)`` + + Compatibility call shape: + ``create(hab, registryName, ...)`` - Canonical usage follows the ecosystem's established camelCase form: - ``create(name, registryName, *, noBackers=True, baks=None, toad=0, nonce=None)``. + Parameters: + target (str | dict): Canonical identifier alias string or legacy + habitat dict. + registryName (str | None): Human-readable alias for the new + registry. Required in both call shapes. + noBackers (bool): Whether to create a no-backer registry. + estOnly (bool | None): Optional establishment-only override. When + omitted in canonical mode, the value is inferred from the + identifier traits. + baks (list[str] | None): Optional backer AIDs. + toad (int): Witness threshold for backer receipts. + nonce (str | None): Optional nonce for deterministic registry + inception. - Compatibility forms stay callable during the parity transition: - ``create(hab, registryName, ...)``. All forms return :class:`RegistryResult`. + Returns: + RegistryResult: Wrapper exposing the locally built events and the + submitted operation response. """ if isinstance(target, str): name = target @@ -190,7 +313,12 @@ def _submit_registry_events(self, *, hab, name, registryName, vcp, ixn, sigs): return self.client.post(path=f"/identifiers/{name}/registries", json=body) def create_from_events(self, hab, registryName, vcp, ixn, sigs): - """Compatibility wrapper returning the legacy operation JSON payload.""" + """Compatibility wrapper over :meth:`createFromEvents`. + + This older Python surface returns the decoded operation JSON directly + instead of :class:`RegistryResult`. Keep it documented as compatibility + behavior, not as a peer maintained API. + """ return self.createFromEvents( hab=hab, name=hab["name"], @@ -203,9 +331,17 @@ def create_from_events(self, hab, registryName, vcp, ixn, sigs): def createFromEvents(self, hab, name, registryName, vcp, ixn, sigs): """Submit a registry creation request from prebuilt local events. + Parameters: + hab (dict): Habitat state for the signing identifier. + name (str): Identifier alias used for the request path. + registryName (str): Registry alias to create. + vcp: Prebuilt registry inception event. + ixn: Prebuilt anchoring interaction event. + sigs (list[str]): Signatures over ``ixn``. + Returns: - RegistryResult: Wrapper exposing the submitted event material and - the decoded operation payload through ``op()``. + RegistryResult: Wrapper exposing the normalized event serders and + the decoded operation payload through :meth:`RegistryResult.op`. """ regser = self._serder_from_event(vcp) serder = self._serder_from_event(ixn) @@ -221,7 +357,12 @@ def createFromEvents(self, hab, name, registryName, vcp, ixn, sigs): @staticmethod def serialize(serder, anc): - """Serialize a registry event plus its anchoring attachment group.""" + """Serialize a TEL event plus the anchoring attachment group it needs. + + This helper is mainly consumed by IPEX grant workflows, where the + holder or verifier needs the issuance or revocation event together with + its source-couple attachment material. + """ seqner = coring.Seqner(sn=anc.sn) couple = seqner.qb64b + anc.said.encode("utf-8") atc = bytearray() @@ -247,6 +388,11 @@ def rename(self, target, registryName, newName): Parameters: target (str | dict): Canonical identifier alias string or legacy habitat dict carrying ``name``. + registryName (str): Current registry alias. + newName (str): Replacement alias to store in KERIA. + + Returns: + dict: Decoded renamed registry record. """ name = target if isinstance(target, str) else target["name"] body = dict(name=newName) @@ -255,7 +401,30 @@ def rename(self, target, registryName, newName): class Credentials: - """Resource wrapper for listing, exporting, issuing, and revoking credentials.""" + """Resource wrapper for stored-credential reads and credential writes. + + Maintained read surface: + + - :meth:`list` + - :meth:`get` + - :meth:`export` + - :meth:`delete` + - :meth:`state` + + Maintained write surface: + + - :meth:`issue` + - :meth:`revoke` + + Compatibility write surface: + + - :meth:`create` + - :meth:`create_from_events` + + Use this class when the concern is the credential itself or its TEL state. + Use :class:`Ipex` when the concern is the peer exchange conversation that + transports a credential between participants. + """ def __init__(self, client: SignifyClient): """Create a credentials resource bound to one Signify client. @@ -266,25 +435,28 @@ def __init__(self, client: SignifyClient): """ self.client = client - def list(self, filtr=None, sort=None, skip=None, limit=None): + def list(self, filter=None, sort=None, skip=0, limit=25): """Query credentials stored by the remote agent. Parameters: - filtr (dict): Credential filter dict - sort(list): list of SAD Path field references to sort by - skip (int): number of credentials to skip at the front of the list - limit (int): total number of credentials to retrieve + filter (dict | None): Maintained credential filter dict. Common + fields in live parity coverage include ``-i`` for issuer, + ``-s`` for schema SAID, and ``-a-i`` for subject AID. + sort (list | None): Optional list of SAD path sort expressions. + skip (int): Number of credentials to skip at the front of the list. + limit (int): Total number of credentials to retrieve. Returns: - list: list of dicts representing the listed credentials + list[dict]: Decoded credential records returned by KERIA. + + This method owns the maintained query contract. The older ``filtr`` + spelling is intentionally gone from the public Python surface. """ - filtr = filtr if filtr is not None else {} sort = sort if sort is not None else [] - skip = skip if skip is not None else 0 - limit = limit if limit is not None else 25 + filter = {} if filter is None else filter body = dict( - filter=filtr, + filter=filter, sort=sort, skip=skip, limit=limit @@ -294,31 +466,120 @@ def list(self, filtr=None, sort=None, skip=None, limit=None): return res.json() def export(self, said): - """Export one credential in CESR JSON form. + """Compatibility alias for CESR retrieval. Parameters: - said (str): SAID of credential to export + said (str): SAID of the credential to export. + Returns: - credential (bytes): exported credential + bytes: Raw CESR response body for the credential. + + ``export`` remains callable for older SignifyPy workflows, but the + maintained contract is :meth:`get` with ``includeCESR=True``. """ - headers = dict(accept="application/json+cesr") + return self.get(said, includeCESR=True) + def get(self, said, includeCESR=False): + """Fetch one credential in JSON or CESR form. + + Parameters: + said (str): SAID of credential to fetch. + includeCESR (bool): When ``True``, request CESR JSON bytes instead + of the default decoded JSON payload. + + Returns: + dict | bytes: Decoded credential JSON when ``includeCESR`` is + ``False``; raw CESR bytes when ``includeCESR`` is ``True``. + + This is the maintained read contract for a single credential. Use + :meth:`export` only when compatibility with older call sites matters. + """ + headers = dict(accept="application/json+cesr" if includeCESR else "application/json") res = self.client.get(f"/credentials/{said}", headers=headers) - return res.content + return res.content if includeCESR else res.json() - def get(self, said): - """Fetch one credential in JSON form, including its current TEL status.""" - res = self.client.get(f"/credentials/{said}") - return res.json() + def delete(self, said): + """Delete one locally stored credential by SAID. + + Parameters: + said (str): SAID of the locally stored credential copy to delete. + + Returns: + None: Success is represented by the absence of an HTTP error. + + This deletes the local stored copy on the connected agent; it does not + revoke the credential. + """ + self.client.delete(f"/credentials/{said}") def state(self, registry_said, credential_said): - """Fetch one credential TEL state record under a registry.""" + """Fetch one credential TEL state record under a registry. + + Parameters: + registry_said (str): Registry SAID that owns the credential TEL. + credential_said (str): Credential SAID whose TEL state is being + queried. + + Returns: + dict: Decoded TEL state record from KERIA. + + This method stays intentionally explicit and KERIA-shaped; it does not + wrap TEL state in a richer Python object. + """ res = self.client.get(f"/registries/{registry_said}/{credential_said}") return res.json() + def issue( + self, + name, + registryName, + data, + schema, + *, + recipient=None, + edges=None, + rules=None, + private=False, + timestamp=None, + ): + """Create and submit a credential issuance request using canonical names. + + Parameters: + name (str): Identifier alias used as the issuer. + registryName (str): Registry alias under the identifier. + data (dict): Credential subject attributes. + schema (str): SAID of the credential schema. + recipient (str | None): Optional recipient AID. + edges (dict | None): Optional source edges for chained credentials. + rules (dict | None): Optional issuance rules block. + private (bool): Whether to issue a privacy-preserving credential. + timestamp (str | None): Optional issuance timestamp override. + + Returns: + CredentialIssueResult: Wrapper exposing the created credential + material plus synchronous ``op()`` access. + + This is the maintained public issuance API. It looks up the issuer + habitat and registry by name, builds the ACDC, issuance event, and + anchoring interaction locally, then submits those events to KERIA. + """ + hab = self.client.identifiers().get(name) + registry = self.client.registries().get(name, registryName) + return self._issue_result( + hab=hab, + registry=registry, + data=data, + schema=schema, + recipient=recipient, + edges=edges, + rules=rules, + private=private, + timestamp=timestamp, + ) + def create(self, hab, registry, data, schema, recipient=None, edges=None, rules=None, private=False, timestamp=None): - """Create and submit a credential issuance request. + """Compatibility wrapper for the older registry-centric issuance API. Parameters: hab (dict): Identifier habitat state used as the issuer. @@ -335,26 +596,139 @@ def create(self, hab, registry, data, schema, recipient=None, edges=None, rules= tuple: ``(creder, iserder, anc, sigs, operation)`` for the credential, issuance event, anchoring interaction, signatures, and KERIA operation payload. + + Keep this method documented as compatibility surface only. New Python + call sites should prefer :meth:`issue`. """ - pre = hab["prefix"] + result = self._issue_result( + hab=hab, + registry=registry, + data=data, + schema=schema, + recipient=recipient, + edges=edges, + rules=rules, + private=private, + timestamp=timestamp, + ) + return result.acdc, result.iss, result.anc, result.sigs, result.op() - if recipient is None: - recp = None - else: - recp = recipient + def create_from_events(self, hab, creder, iss, anc, sigs): + """Submit a credential issuance request from prebuilt local events. + + Parameters: + hab (dict): Habitat state for the signing identifier. + creder (dict): Prebuilt credential SAD. + iss (dict): Prebuilt issuance TEL event SAD. + anc (dict): Prebuilt anchoring interaction SAD. + sigs (list[str]): Signatures over ``anc``. + + Returns: + requests.Response: Raw HTTP response from KERIA. + This is the low-level replay surface used by advanced and multisig + flows that must resubmit an exact stored proposal instead of rebuilding + events locally. + """ + body = dict( + acdc=creder, + iss=iss, + ixn=anc, + sigs=sigs + ) + keeper = self.client.manager.get(aid=hab) + body[keeper.algo] = keeper.params() + name = hab["name"] + + return self.client.post(f"/identifiers/{name}/credentials", json=body) + + def revoke(self, name, said, *, timestamp=None): + """Create and submit a credential revocation request. + + Parameters: + name (str): Identifier alias used as the revoking issuer. + said (str): SAID of the credential to revoke. + timestamp (str | None): Optional revoke timestamp override. + + Returns: + CredentialRevokeResult: Wrapper exposing the created revocation + material plus synchronous ``op()`` access. + + The implementation first reads the stored credential to recover its + registry reference and current TEL status, then builds the revoke event + and anchoring interaction locally before submitting the delete request. + """ + return self._revoke_result(name=name, said=said, timestamp=timestamp) + + def _issue_result( + self, + *, + hab, + registry, + data, + schema, + recipient=None, + edges=None, + rules=None, + private=False, + timestamp=None, + ): + creder, iserder, anc, sigs = self._build_issue_artifacts( + hab=hab, + registry=registry, + data=data, + schema=schema, + recipient=recipient, + edges=edges, + rules=rules, + private=private, + timestamp=timestamp, + ) + response = self.create_from_events( + hab=hab, + creder=creder.sad, + iss=iserder.sad, + anc=anc.sad, + sigs=sigs, + ) + return CredentialIssueResult( + acdc=creder, + iss=iserder, + anc=anc, + sigs=sigs, + response=response, + ) + + def _build_issue_artifacts( + self, + *, + hab, + registry, + data, + schema, + recipient=None, + edges=None, + rules=None, + private=False, + timestamp=None, + ): + pre = hab["prefix"] + recp = recipient if recipient is not None else None + body_data = dict(data) if timestamp is not None: - data["dt"] = timestamp + body_data["dt"] = timestamp regk = registry['regk'] - creder = proving.credential(issuer=registry['pre'], - schema=schema, - recipient=recp, - data=data, - source=edges, - private=private, - rules=rules, - status=regk) + creder = proving.credential( + issuer=registry['pre'], + schema=schema, + recipient=recp, + data=body_data, + source=edges, + private=private, + rules=rules, + status=regk, + ) dt = creder.attrib["dt"] if "dt" in creder.attrib else helping.nowIso8601() noBackers = 'NB' in registry['state']['c'] @@ -363,7 +737,7 @@ def create(self, hab, registry, data, schema, recipient=None, edges=None, rules= else: regi = registry['state']['s'] try: - regi = int(regi) # value is hex though should be parsed as int prior to passing in to backerIssue where it is reconverted to hex + regi = int(regi) except ValueError: raise ValueError(f"invalid registry state sn={regi}") regd = registry['state']['d'] @@ -372,45 +746,42 @@ def create(self, hab, registry, data, schema, recipient=None, edges=None, rules= vcid = iserder.ked["i"] rseq = coring.Seqner(snh=iserder.ked["s"]) rseal = eventing.SealEvent(vcid, rseq.snh, iserder.said) - rseal = dict(i=rseal.i, s=rseal.s, d=rseal.d) - - data = [rseal] + anchor_data = [dict(i=rseal.i, s=rseal.s, d=rseal.d)] state = hab["state"] sn = int(state["s"], 16) dig = state["d"] - anc = interact(pre, sn=sn + 1, data=data, dig=dig) + anc = interact(pre, sn=sn + 1, data=anchor_data, dig=dig) keeper = self.client.manager.get(aid=hab) sigs = keeper.sign(ser=anc.raw) + return creder, iserder, anc, sigs - res = self.create_from_events(hab=hab, creder=creder.sad, iss=iserder.sad, anc=anc.sad, - sigs=sigs) - - return creder, iserder, anc, sigs, res.json() - - def create_from_events(self, hab, creder, iss, anc, sigs): - """Submit a credential issuance request from prebuilt local events.""" - body = dict( - acdc=creder, - iss=iss, - ixn=anc, - sigs=sigs + def _revoke_result(self, *, name, said, timestamp=None): + hab, rserder, anc, sigs = self._build_revoke_artifacts( + name=name, + said=said, + timestamp=timestamp, ) keeper = self.client.manager.get(aid=hab) + body = dict( + rev=rserder.ked, + ixn=anc.ked, + sigs=sigs, + ) body[keeper.algo] = keeper.params() - name = hab["name"] - - return self.client.post(f"/identifiers/{name}/credentials", json=body) - - def revoke(self, name, said, timestamp=None): - """Create and submit a credential revocation request. + response = self.client.delete( + f"/identifiers/{name}/credentials/{said}", + body=body, + ) + return CredentialRevokeResult( + rev=rserder, + anc=anc, + sigs=sigs, + response=response, + ) - Returns: - tuple: ``(rserder, anc, sigs, operation)`` for the locally created - TEL revocation event, its anchoring interaction, its signatures, - and the KERIA operation payload. - """ + def _build_revoke_artifacts(self, *, name, said, timestamp=None): hab = self.client.identifiers().get(name) pre = hab["prefix"] dt = timestamp or helping.nowIso8601() @@ -441,24 +812,37 @@ def revoke(self, name, said, timestamp=None): keeper = self.client.manager.get(aid=hab) sigs = keeper.sign(ser=anc.raw) + return hab, rserder, anc, sigs - body = dict( - rev=rserder.ked, - ixn=anc.ked, - sigs=sigs, - ) - body[keeper.algo] = keeper.params() - operation = self.client.delete( - f"/identifiers/{name}/credentials/{said}", - body=body, - ).json() +class Ipex: + """Resource wrapper for IPEX credential conversation and presentation. - return rserder, anc, sigs, operation + The maintained IPEX conversation order is: + ``apply -> offer -> agree -> grant -> admit`` -class Ipex: - """Resource wrapper for IPEX peer exchange message construction and submission.""" + Builder methods create one signed ``exn`` message plus any attachment + material needed for transport: + + - :meth:`apply` + - :meth:`offer` + - :meth:`agree` + - :meth:`grant` + - :meth:`admit` + + Submit methods send those prebuilt messages to KERIA: + + - :meth:`submitApply` + - :meth:`submitOffer` + - :meth:`submitAgree` + - :meth:`submitGrant` + - :meth:`submitAdmit` + + Use :class:`Ipex` when the operation is about exchanging credential-related + peer messages, not when it is about issuing, revoking, or reading stored + credentials. + """ def __init__(self, client: SignifyClient): """Create an IPEX resource bound to one Signify client. @@ -469,13 +853,313 @@ def __init__(self, client: SignifyClient): """ self.client = client - def grant(self, hab, recp, message, acdc, iss, anc, agree=None, dt=None): - """Create an IPEX grant exchange message for a recipient. + def _hab(self, name): + """Resolve one identifier habitat for the maintained name-based API.""" + return self.client.identifiers().get(name) + + @staticmethod + def _said(value): + """Normalize either a SAID string or an object exposing ``said``.""" + if value is None: + return None + if isinstance(value, str): + return value + return value.said + + @staticmethod + def _acdc_embed(acdc): + """Normalize one offered ACDC into the raw CESR stream KERIA expects. + + The public IPEX surface accepts either raw CESR bytes, a decoded ACDC + SAD dict, or a serder-like object with ``raw``. + """ + if isinstance(acdc, (bytes, bytearray)): + return bytes(acdc) + if isinstance(acdc, dict): + return serdering.SerderACDC(sad=acdc).raw + return acdc.raw + + @staticmethod + def _keri_embed(message): + """Normalize one KERI event into the raw CESR stream KERIA expects. + + The public IPEX surface accepts either raw CESR bytes, a decoded KERI + SAD dict, or a serder-like object with ``raw``. + """ + if message is None: + return None + if isinstance(message, (bytes, bytearray)): + return bytes(message) + if isinstance(message, dict): + return serdering.SerderKERI(sad=message).raw + return message.raw + + @staticmethod + def _append_attachment(raw, attachment): + """Append one optional attachment to a raw CESR stream. + + KERIA responses may expose attachment material as a string or as a + list of attachment fragments. This helper flattens that shape into one + byte stream suitable for embedding in an exchange message. + """ + if attachment is None: + return raw + if isinstance(attachment, (list, tuple)): + attachment = "".join(attachment) + if isinstance(attachment, str): + attachment = attachment.encode("utf-8") + return bytes(raw) + bytes(attachment) + + def apply( + self, + name, + recipient, + schemaSaid, + *, + message="", + attributes=None, + dt=None, + datetime=None, + ): + """Create an IPEX apply exchange for a requested credential shape. + + Parameters: + name (str): Identifier alias used as the IPEX sender. + recipient (str): Recipient AID that should receive the apply. + schemaSaid (str): Schema SAID describing the requested credential. + message (str): Optional human-readable message. + attributes (dict | None): Optional attribute filter/request block. + dt (str | None): Canonical timestamp for the exchange message. + datetime (str | None): Compatibility alias for ``dt``. + + Returns: + tuple: ``(exn, sigs, atc)`` where ``exn`` is the built exchange + serder, ``sigs`` are signatures over it, and ``atc`` is attachment + material. ``apply`` normally returns an empty attachment string. + + ``apply`` starts an IPEX conversation, so it does not reference a + prior conversation SAID. + """ + hab = self._hab(name) + exchanges = self.client.exchanges() + data = dict( + m=message, + s=schemaSaid, + a=attributes or {}, + ) + return exchanges.createExchangeMessage( + sender=hab, + route="/ipex/apply", + payload=data, + embeds={}, + recipient=recipient, + dt=dt, + datetime=datetime, + ) + + def submitApply(self, name, exn, sigs, recp): + """Send a precreated IPEX apply exchange to recipients. + + Parameters: + name (str): Identifier alias used in the KERIA request path. + exn: Prebuilt apply exchange serder. + sigs (list[str]): Signatures over ``exn``. + recp (list[str]): Recipient AIDs that should receive the apply. + + Returns: + dict: Decoded KERIA operation payload. + """ + body = dict( + exn=exn.ked, + sigs=sigs, + rec=recp, + ) + res = self.client.post(f"/identifiers/{name}/ipex/apply", json=body) + return res.json() + + def offer( + self, + name, + recipient, + acdc, + *, + message="", + applySaid=None, + dt=None, + datetime=None, + ): + """Create an IPEX offer exchange that discloses one metadata ACDC. + + Parameters: + name (str): Identifier alias used as the IPEX sender. + recipient (str): Recipient AID that should receive the offer. + acdc: Offered credential metadata in raw, dict, or serder form. + message (str): Optional human-readable message. + applySaid (str | None): Optional SAID of the prior ``apply`` this + offer answers. + dt (str | None): Canonical timestamp for the exchange message. + datetime (str | None): Compatibility alias for ``dt``. + + Returns: + tuple: ``(exn, sigs, atc)`` where ``atc`` contains the pathed + attachment material for the embedded ``acdc`` payload. + """ + hab = self._hab(name) + exchanges = self.client.exchanges() + data = dict(m=message) + return exchanges.createExchangeMessage( + sender=hab, + route="/ipex/offer", + payload=data, + embeds=dict(acdc=self._acdc_embed(acdc)), + recipient=recipient, + dig=applySaid, + dt=dt, + datetime=datetime, + ) + + def submitOffer(self, name, exn, sigs, atc, recp): + """Send a precreated IPEX offer exchange to recipients. + + Parameters: + name (str): Identifier alias used in the KERIA request path. + exn: Prebuilt offer exchange serder. + sigs (list[str]): Signatures over ``exn``. + atc (str | bytes): Attachment material returned by :meth:`offer`. + recp (list[str]): Recipient AIDs that should receive the offer. + + Returns: + dict: Decoded KERIA operation payload. + """ + body = dict( + exn=exn.ked, + sigs=sigs, + atc=atc, + rec=recp, + ) + res = self.client.post(f"/identifiers/{name}/ipex/offer", json=body) + return res.json() + + def agree( + self, + name, + recipient, + offerSaid, + *, + message="", + dt=None, + datetime=None, + ): + """Create an IPEX agree exchange that acknowledges an offered credential. + + Parameters: + name (str): Identifier alias used as the IPEX sender. + recipient (str): Recipient AID that should receive the agree. + offerSaid (str): SAID of the prior ``offer`` being accepted. + message (str): Optional human-readable message. + dt (str | None): Canonical timestamp for the exchange message. + datetime (str | None): Compatibility alias for ``dt``. + + Returns: + tuple: ``(exn, sigs, atc)`` for the agree exchange. ``agree`` + normally returns an empty attachment string. + """ + hab = self._hab(name) + exchanges = self.client.exchanges() + data = dict(m=message) + return exchanges.createExchangeMessage( + sender=hab, + route="/ipex/agree", + payload=data, + embeds={}, + recipient=recipient, + dig=offerSaid, + dt=dt, + datetime=datetime, + ) + + def submitAgree(self, name, exn, sigs, recp): + """Send a precreated IPEX agree exchange to recipients. + + Parameters: + name (str): Identifier alias used in the KERIA request path. + exn: Prebuilt agree exchange serder. + sigs (list[str]): Signatures over ``exn``. + recp (list[str]): Recipient AIDs that should receive the agree. + + Returns: + dict: Decoded KERIA operation payload. + """ + body = dict( + exn=exn.ked, + sigs=sigs, + rec=recp, + ) + res = self.client.post(f"/identifiers/{name}/ipex/agree", json=body) + return res.json() + + def grant( + self, + hab=None, + recp=None, + message="", + acdc=None, + iss=None, + anc=None, + agree=None, + dt=None, + *, + name=None, + recipient=None, + agreeSaid=None, + acdcAttachment=None, + issAttachment=None, + ancAttachment=None, + datetime=None, + ): + """Create an IPEX grant exchange for credential presentation. + + Maintained call shape: + ``grant(name=..., recipient=..., acdc=..., iss=..., anc=..., ...)`` + + Compatibility call shape: + ``grant(hab, recp=..., acdc=..., iss=..., anc=..., ...)`` + + Parameters: + hab (dict | None): Legacy habitat dict for the compatibility form. + recp (str | None): Legacy recipient parameter for the compatibility + form. + message (str): Optional human-readable message. + acdc: Credential payload in raw, dict, or serder form. + iss: Issuance TEL event in raw, dict, or serder form. + anc: Anchoring KEL event in raw, dict, or serder form. + agree: Optional legacy agree serder or SAID. + dt (str | None): Canonical timestamp for the exchange message. + name (str | None): Maintained identifier alias used as the sender. + recipient (str | None): Maintained recipient AID. + agreeSaid (str | None): SAID of the prior ``agree`` this grant + answers. + acdcAttachment: Optional ACDC attachment material. When omitted, + ``acdc`` is embedded as given. + issAttachment: Optional issuance attachment material. + ancAttachment: Optional anchoring attachment material. + datetime (str | None): Compatibility alias for ``dt``. Returns: tuple: ``(exn, sigs, atc)`` for the grant exchange message, its signatures, and any attachment material. + + ``grant`` is the first IPEX step that normally carries non-empty + embedded attachments because it transports the actual credential, + issuance event, and anchoring evidence. """ + if name is not None: + hab = self._hab(name) + if recipient is not None: + recp = recipient + if agreeSaid is not None: + agree = agreeSaid + exchanges = self.client.exchanges() data = dict( m=message, @@ -483,18 +1167,18 @@ def grant(self, hab, recp, message, acdc, iss, anc, agree=None, dt=None): ) embeds = dict( - acdc=acdc, - iss=iss, - anc=anc + acdc=self._append_attachment(self._acdc_embed(acdc), acdcAttachment), + iss=self._append_attachment(self._keri_embed(iss), issAttachment), + anc=self._append_attachment(self._keri_embed(anc), ancAttachment), ) kwa = dict() if agree is not None: - kwa['dig'] = agree.said + kwa['dig'] = self._said(agree) grant, gsigs, atc = exchanges.createExchangeMessage(sender=hab, route="/ipex/grant", payload=data, embeds=embeds, recipient=recp, - dt=dt, **kwa) + dt=dt, datetime=datetime, **kwa) return grant, gsigs, atc @@ -503,13 +1187,14 @@ def submitGrant(self, name, exn, sigs, atc, recp): Parameters: name (str): human readable identifier alias to send from - exn (Serder): peer-to-peer message to send + exn: Prebuilt grant exchange serder. sigs (list): qb64 signatures over the exn - atc (string|bytes): additional attachments for exn (usually pathed signatures over embeds) - recp (list[string]): qb64 recipient AID + atc (string|bytes): Additional pathed attachment material returned + by :meth:`grant`. + recp (list[string]): Recipient AIDs that should receive the grant. Returns: - dict: operation response from KERIA + dict: Decoded KERIA operation payload. """ body = dict( @@ -522,8 +1207,49 @@ def submitGrant(self, name, exn, sigs, atc, recp): res = self.client.post(f"/identifiers/{name}/ipex/grant", json=body) return res.json() - def admit(self, hab, message, grant, recp, dt=None): - """Create an IPEX admit exchange that references an existing grant.""" + def admit( + self, + hab=None, + message="", + grant=None, + recp=None, + dt=None, + *, + name=None, + recipient=None, + grantSaid=None, + datetime=None, + ): + """Create an IPEX admit exchange that acknowledges a prior grant. + + Maintained call shape: + ``admit(name=..., recipient=..., grantSaid=..., ...)`` + + Compatibility call shape: + ``admit(hab, message, grant, recp, dt=None)`` + + Parameters: + hab (dict | None): Legacy habitat dict for the compatibility form. + message (str): Optional human-readable message. + grant (str | None): Legacy grant SAID parameter. + recp (str | None): Legacy recipient parameter. + dt (str | None): Canonical timestamp for the exchange message. + name (str | None): Maintained identifier alias used as the sender. + recipient (str | None): Maintained recipient AID. + grantSaid (str | None): SAID of the prior ``grant`` being + acknowledged. + datetime (str | None): Compatibility alias for ``dt``. + + Returns: + tuple: ``(exn, sigs, atc)`` for the admit exchange. ``admit`` + normally returns an empty attachment string. + """ + if name is not None: + hab = self._hab(name) + if recipient is not None: + recp = recipient + if grantSaid is not None: + grant = grantSaid if not grant: raise ValueError(f"invalid grant={grant}") @@ -534,7 +1260,7 @@ def admit(self, hab, message, grant, recp, dt=None): admit, asigs, atc = exchanges.createExchangeMessage(sender=hab, route="/ipex/admit", payload=data, embeds=None, recipient=recp, - dt=dt, dig=grant) + dt=dt, datetime=datetime, dig=grant) return admit, asigs, atc @@ -543,12 +1269,14 @@ def submitAdmit(self, name, exn, sigs, atc, recp): Parameters: name (str): human readable identifier alias to send from - exn (bytes): stream byte string of peer-to-peer message to send - admit (bytes): stream byte string of admit exn message - recp (list[string]): qb64 recipient AID + exn: Prebuilt admit exchange serder. + sigs (list[str]): Signatures over ``exn``. + atc (str | bytes): Additional attachment material returned by + :meth:`admit`. + recp (list[string]): Recipient AIDs that should receive the admit. Returns: - dict: operation response from KERIA + dict: Decoded KERIA operation payload. """ body = dict( diff --git a/tests/app/test_credentialing.py b/tests/app/test_credentialing.py index 1798df4..82f8455 100644 --- a/tests/app/test_credentialing.py +++ b/tests/app/test_credentialing.py @@ -262,23 +262,187 @@ def test_credentials_list(make_mock_response): 'sort': ['updside down'], 'skip': 10, 'limit': 10}).thenReturn(mock_response) from signify.app.credentialing import Credentials - Credentials(client=mock_client).list(filtr={'genre': 'horror'}, sort=['updside down'], skip=10, + Credentials(client=mock_client).list(filter={'genre': 'horror'}, sort=['updside down'], skip=10, limit=10) # type: ignore verify(mock_response, times=1).json() + +def test_credentials_get_json(make_mock_response): + from signify.app.clienting import SignifyClient + mock_client = mock(spec=SignifyClient, strict=True) + + payload = {'sad': {'d': 'a_said'}} + mock_response = make_mock_response({'json': lambda: payload}) + expect(mock_client, times=1).get('/credentials/a_said', + headers={'accept': 'application/json'}).thenReturn(mock_response) + + from signify.app.credentialing import Credentials + out = Credentials(client=mock_client).get('a_said') # type: ignore + + assert out == payload + + +def test_credentials_get_cesr(make_mock_response): + from signify.app.clienting import SignifyClient + mock_client = mock(spec=SignifyClient, strict=True) + + mock_response = make_mock_response({'content': b'things I found'}) + expect(mock_client, times=1).get('/credentials/a_said', + headers={'accept': 'application/json+cesr'}).thenReturn(mock_response) + + from signify.app.credentialing import Credentials + out = Credentials(client=mock_client).get('a_said', includeCESR=True) # type: ignore + + assert out == b'things I found' + + def test_credentials_export(make_mock_response): from signify.app.clienting import SignifyClient mock_client = mock(spec=SignifyClient, strict=True) - mock_response = make_mock_response({'content': 'things I found'}) + mock_response = make_mock_response({'content': b'things I found'}) expect(mock_client, times=1).get('/credentials/a_said', headers={'accept': 'application/json+cesr'}).thenReturn(mock_response) from signify.app.credentialing import Credentials out = Credentials(client=mock_client).export('a_said') # type: ignore - assert out == 'things I found' + assert out == b'things I found' + + +def test_credentials_delete(make_mock_response): + from signify.app.clienting import SignifyClient + mock_client = mock(spec=SignifyClient, strict=True) + + mock_response = make_mock_response({}) + expect(mock_client, times=1).delete('/credentials/a_said').thenReturn(mock_response) + + from signify.app.credentialing import Credentials + out = Credentials(client=mock_client).delete('a_said') # type: ignore + + assert out is None + + +def test_credentials_state(make_mock_response): + from signify.app.clienting import SignifyClient + mock_client = mock(spec=SignifyClient, strict=True) + + payload = {'et': 'iss', 's': '0'} + mock_response = make_mock_response({'json': lambda: payload}) + expect(mock_client, times=1).get('/registries/registry_said/credential_said').thenReturn(mock_response) + + from signify.app.credentialing import Credentials + out = Credentials(client=mock_client).state('registry_said', 'credential_said') # type: ignore + + assert out == payload + + +def test_credential_issue_result_op_and_iterable(make_mock_response): + mock_response = make_mock_response({"json": lambda: {"done": True}}) + expect(mock_response, times=2).json().thenReturn({"done": True}) + + result = credentialing.CredentialIssueResult( + acdc="acdc", + iss="iss", + anc="anc", + sigs=["a signature"], + response=mock_response, + ) + + assert result.acdc == "acdc" + assert result.iss == "iss" + assert result.anc == "anc" + assert result.sigs == ["a signature"] + assert result.op() == {"done": True} + assert tuple(result) == ("acdc", "iss", "anc", ["a signature"], {"done": True}) + + +def test_credential_revoke_result_op_and_iterable(make_mock_response): + mock_response = make_mock_response({"json": lambda: {"done": True}}) + expect(mock_response, times=2).json().thenReturn({"done": True}) + + result = credentialing.CredentialRevokeResult( + rev="rev", + anc="anc", + sigs=["a signature"], + response=mock_response, + ) + + assert result.rev == "rev" + assert result.anc == "anc" + assert result.sigs == ["a signature"] + assert result.op() == {"done": True} + assert tuple(result) == ("rev", "anc", ["a signature"], {"done": True}) + + +def test_credentials_issue(make_mock_client_with_manager, make_mock_response): + mock_client, mock_manager = make_mock_client_with_manager() + + from signify.app.aiding import Identifiers + from signify.app.credentialing import Registries + from signify.core import keeping + + mock_ids = mock(spec=Identifiers, strict=True) + mock_regs = mock(spec=Registries, strict=True) + mock_hab = {'prefix': 'ELI7pg979AdhmvrjDeam2eAO2SR5niCgnjAJXJHtJose', 'name': 'aid1', + 'state': {'s': '1', 'd': "ABCDEFG"}} + mock_registry = {'regk': "EKRg7i8jS4O6BYUYiQG7X8YiMYdDXdw28tJRhFndCdGF", + 'pre': 'EHpwssa6tmD2U5W7-aogym-r1NobKBNXydP4MmaebA4O', 'state': {'c': ['NB']}} + data = dict(dt="2023-09-27T16:27:14.376928+00:00", LEI="ABC1234567890AD4456") + schema = "EBfdlu8R27Fbx-ehrqwImnK-8Cm79sqbAQ4MmvEAYqao" + recp = "ELI7pg979AdhmvrjDeam2eAO2SR5niCgnjAJXJHtJose" + + expect(mock_client, times=1).identifiers().thenReturn(mock_ids) + expect(mock_ids, times=1).get("aid1").thenReturn(mock_hab) + expect(mock_client, times=1).registries().thenReturn(mock_regs) + expect(mock_regs, times=1).get("aid1", "reg1").thenReturn(mock_registry) + + mock_keeper = mock({'algo': 'salty', 'params': lambda: {'keeper': 'params'}}, spec=keeping.SaltyKeeper, strict=True) + expect(mock_manager, times=2).get(aid=mock_hab).thenReturn(mock_keeper) + expect(mock_keeper, times=1).sign(ser=ANY()).thenReturn(['a signature']) + mock_response = make_mock_response({}) + expect(mock_response, times=2).json().thenReturn({'v': 'ACDC10JSON00014c_'}) + + body = {'acdc': {'v': 'ACDC10JSON000196_', 'd': 'EK2xYrVkfJJHvlGhP79sfEPvQGmkFPPNAj-bjI5oHy7m', + 'i': 'EHpwssa6tmD2U5W7-aogym-r1NobKBNXydP4MmaebA4O', + 'ri': 'EKRg7i8jS4O6BYUYiQG7X8YiMYdDXdw28tJRhFndCdGF', + 's': 'EBfdlu8R27Fbx-ehrqwImnK-8Cm79sqbAQ4MmvEAYqao', + 'a': {'d': 'EHpwssa6tmD2U5W7-aogym-r1NobKBNXydP4MmaebA4O', + 'i': 'ELI7pg979AdhmvrjDeam2eAO2SR5niCgnjAJXJHtJose', + 'dt': '2023-09-27T16:27:14.376928+00:00', 'LEI': 'ABC1234567890AD4456'}}, + 'iss': {'v': 'KERI10JSON0000ed_', 't': 'iss', 'd': 'EE8yncw1LCyBVtZPtozAFi7qvGn9dRPwTbuq--ulOAtB', + 'i': 'EK2xYrVkfJJHvlGhP79sfEPvQGmkFPPNAj-bjI5oHy7m', 's': '0', + 'ri': 'EKRg7i8jS4O6BYUYiQG7X8YiMYdDXdw28tJRhFndCdGF', 'dt': '2023-09-27T16:27:14.376928+00:00'}, + 'ixn': {'v': 'KERI10JSON000115_', 't': 'ixn', 'd': 'EC5KxyucpxnOpIpHe2QUPs9YeH1yGvkALg8NcWLYFe6a', + 'i': 'ELI7pg979AdhmvrjDeam2eAO2SR5niCgnjAJXJHtJose', 's': '2', 'p': 'ABCDEFG', 'a': [ + {'i': 'EK2xYrVkfJJHvlGhP79sfEPvQGmkFPPNAj-bjI5oHy7m', 's': '0', + 'd': 'EE8yncw1LCyBVtZPtozAFi7qvGn9dRPwTbuq--ulOAtB'}]}, 'sigs': ['a signature'], + 'salty': {'keeper': 'params'}} + + expect(mock_client, times=1).post("/identifiers/aid1/credentials", json=body).thenReturn(mock_response) + + result = credentialing.Credentials(client=mock_client).issue( + "aid1", + "reg1", + data, + schema, + recipient=recp, + ) + + assert isinstance(result, credentialing.CredentialIssueResult) + assert result.acdc.said == "EK2xYrVkfJJHvlGhP79sfEPvQGmkFPPNAj-bjI5oHy7m" + assert result.iss.said == "EE8yncw1LCyBVtZPtozAFi7qvGn9dRPwTbuq--ulOAtB" + assert result.anc.said == "EC5KxyucpxnOpIpHe2QUPs9YeH1yGvkALg8NcWLYFe6a" + assert result.sigs == ['a signature'] + assert result.op() == {'v': 'ACDC10JSON00014c_'} + assert tuple(result) == ( + result.acdc, + result.iss, + result.anc, + ['a signature'], + {'v': 'ACDC10JSON00014c_'}, + ) def test_credentials_create(make_mock_client_with_manager, make_mock_response): mock_client, mock_manager = make_mock_client_with_manager() @@ -333,6 +497,152 @@ def test_credentials_create(make_mock_client_with_manager, make_mock_response): assert ixn.said == "EC5KxyucpxnOpIpHe2QUPs9YeH1yGvkALg8NcWLYFe6a" assert op == {'v': 'ACDC10JSON00014c_'} + +def test_credentials_revoke_uses_ri_and_returns_result(make_mock_response): + credential_said = "EMwcsEMUEruPXVwPCW7zmqmN8m0I3CihxolBm-RDrsJo" + registry_said = "EGK216v1yguLfex4YRFnG7k1sXRjh3OKY7QqzdKsx7df" + status_said = "ELUvZ8aJEHAQE-0nsevyYTP98rBbGJUrTj5an-pCmwrK" + + class DummyKeeper: + algo = "salty" + + @staticmethod + def params(): + return {"keeper": "params"} + + @staticmethod + def sign(ser): + return ["a signature"] + + class DummyManager: + @staticmethod + def get(aid): + return DummyKeeper() + + class DummyIdentifiers: + @staticmethod + def get(name): + assert name == "aid1" + return {"prefix": "ELI7pg979AdhmvrjDeam2eAO2SR5niCgnjAJXJHtJose", "name": "aid1", + "state": {"s": "1", "d": "ABCDEFG"}} + + class DummyResponse: + @staticmethod + def json(): + return {"done": True} + + class DummyClient: + def __init__(self): + self.manager = DummyManager() + self.last_delete = None + + @staticmethod + def identifiers(): + return DummyIdentifiers() + + @staticmethod + def get(path, headers=None): + assert path == f"/credentials/{credential_said}" + assert headers == {"accept": "application/json"} + return make_mock_response({ + "json": lambda: { + "sad": {"d": credential_said, "ri": registry_said}, + "status": {"d": status_said}, + } + }) + + def delete(self, path, body=None): + self.last_delete = (path, body) + return DummyResponse() + + client = DummyClient() + result = credentialing.Credentials(client=client).revoke( + "aid1", + credential_said, + timestamp="2023-09-27T16:27:14.376928+00:00", + ) + + assert isinstance(result, credentialing.CredentialRevokeResult) + assert result.rev.ked["t"] == "rev" + assert result.rev.ked["ri"] == registry_said + assert result.rev.ked["i"] == credential_said + assert result.rev.ked["dt"] == "2023-09-27T16:27:14.376928+00:00" + assert result.anc.ked["t"] == "ixn" + assert result.sigs == ["a signature"] + assert result.op() == {"done": True} + assert tuple(result) == (result.rev, result.anc, ["a signature"], {"done": True}) + assert client.last_delete[0] == f"/identifiers/aid1/credentials/{credential_said}" + assert client.last_delete[1]["rev"]["ri"] == registry_said + assert client.last_delete[1]["rev"]["i"] == credential_said + assert client.last_delete[1]["ixn"]["t"] == "ixn" + assert client.last_delete[1]["sigs"] == ["a signature"] + assert client.last_delete[1]["salty"] == {"keeper": "params"} + + +def test_credentials_revoke_falls_back_to_rd(make_mock_response): + credential_said = "EMwcsEMUEruPXVwPCW7zmqmN8m0I3CihxolBm-RDrsJo" + registry_said = "EGK216v1yguLfex4YRFnG7k1sXRjh3OKY7QqzdKsx7df" + status_said = "ELUvZ8aJEHAQE-0nsevyYTP98rBbGJUrTj5an-pCmwrK" + + class DummyKeeper: + algo = "salty" + + @staticmethod + def params(): + return {"keeper": "params"} + + @staticmethod + def sign(ser): + return ["a signature"] + + class DummyManager: + @staticmethod + def get(aid): + return DummyKeeper() + + class DummyIdentifiers: + @staticmethod + def get(name): + return {"prefix": "ELI7pg979AdhmvrjDeam2eAO2SR5niCgnjAJXJHtJose", "name": name, + "state": {"s": "1", "d": "ABCDEFG"}} + + class DummyResponse: + @staticmethod + def json(): + return {"done": True} + + class DummyClient: + def __init__(self): + self.manager = DummyManager() + self.last_delete = None + + @staticmethod + def identifiers(): + return DummyIdentifiers() + + @staticmethod + def get(path, headers=None): + return make_mock_response({ + "json": lambda: { + "sad": {"d": credential_said, "rd": registry_said}, + "status": {"d": status_said}, + } + }) + + def delete(self, path, body=None): + self.last_delete = (path, body) + return DummyResponse() + + client = DummyClient() + result = credentialing.Credentials(client=client).revoke( + "aid1", + credential_said, + timestamp="2023-09-27T16:27:14.376928+00:00", + ) + + assert result.rev.ked["ri"] == registry_said + assert client.last_delete[1]["rev"]["ri"] == registry_said + def test_ipex_grant(): from signify.app.clienting import SignifyClient mock_client = mock(spec=SignifyClient, strict=True) @@ -342,9 +652,9 @@ def test_ipex_grant(): dt = "2023-09-25T16:01:37.000000+00:00" mock_hab = {'prefix': 'a_prefix', 'name': 'aid1', 'state': {'s': '1', 'd': "ABCDEFG"}} - mock_acdc = {} - mock_iss = {} - mock_anc = {} + mock_acdc = b"ACDC" + mock_iss = b"ISS" + mock_anc = b"ANC" mock_agree = mock({'said': 'EAGREE123'}, strict=True) mock_grant = {} mock_gsigs = [] @@ -353,9 +663,10 @@ def test_ipex_grant(): expect(mock_excs).createExchangeMessage(sender=mock_hab, route="/ipex/grant", payload={'m': 'this is a test', 'i': 'ELI7pg979AdhmvrjDeam2eAO2SR5niCgnjAJXJHtJose'}, - embeds={'acdc': {}, 'iss': {}, 'anc': {}}, + embeds={'acdc': b'ACDC', 'iss': b'ISS', 'anc': b'ANC'}, recipient='ELI7pg979AdhmvrjDeam2eAO2SR5niCgnjAJXJHtJose', dt=dt, + datetime=None, dig='EAGREE123').thenReturn((mock_grant, mock_gsigs, mock_atc)) ipex = credentialing.Ipex(mock_client) @@ -368,6 +679,231 @@ def test_ipex_grant(): assert gsigs == mock_gsigs assert atc == mock_atc + +def test_ipex_apply(): + from signify.app.clienting import SignifyClient + from signify.app.aiding import Identifiers + from signify.app.exchanging import Exchanges + + mock_client = mock(spec=SignifyClient, strict=True) + mock_ids = mock(spec=Identifiers, strict=True) + mock_excs = mock(spec=Exchanges, strict=True) + + dt = "2023-09-25T16:01:37.000000+00:00" + mock_hab = {"prefix": "a_prefix", "name": "aid1", "state": {"s": "1", "d": "ABCDEFG"}} + mock_apply = {} + mock_sigs = [] + mock_atc = "" + + expect(mock_client, times=1).identifiers().thenReturn(mock_ids) + expect(mock_ids, times=1).get("aid1").thenReturn(mock_hab) + expect(mock_client, times=1).exchanges().thenReturn(mock_excs) + expect(mock_excs, times=1).createExchangeMessage( + sender=mock_hab, + route="/ipex/apply", + payload={"m": "please share", "s": "ESCHEMA", "a": {"LEI": "5493001KJTIIGC8Y1R17"}}, + embeds={}, + recipient="ERECIPIENT", + dt=dt, + datetime=None, + ).thenReturn((mock_apply, mock_sigs, mock_atc)) + + apply, sigs, atc = credentialing.Ipex(mock_client).apply( + "aid1", + "ERECIPIENT", + "ESCHEMA", + message="please share", + attributes={"LEI": "5493001KJTIIGC8Y1R17"}, + dt=dt, + ) + + assert apply == mock_apply + assert sigs == mock_sigs + assert atc == mock_atc + + +def test_ipex_offer(): + from signify.app.clienting import SignifyClient + from signify.app.aiding import Identifiers + from signify.app.exchanging import Exchanges + + class DummyAcdc: + raw = b'{"v":"ACDC10JSON000000_"}' + + mock_client = mock(spec=SignifyClient, strict=True) + mock_ids = mock(spec=Identifiers, strict=True) + mock_excs = mock(spec=Exchanges, strict=True) + + dt = "2023-09-25T16:01:37.000000+00:00" + mock_hab = {"prefix": "a_prefix", "name": "aid1", "state": {"s": "1", "d": "ABCDEFG"}} + mock_offer = {} + mock_sigs = [] + mock_atc = "" + + expect(mock_client, times=1).identifiers().thenReturn(mock_ids) + expect(mock_ids, times=1).get("aid1").thenReturn(mock_hab) + expect(mock_client, times=1).exchanges().thenReturn(mock_excs) + expect(mock_excs, times=1).createExchangeMessage( + sender=mock_hab, + route="/ipex/offer", + payload={"m": "this matches"}, + embeds={"acdc": DummyAcdc.raw}, + recipient="ERECIPIENT", + dig="EAPPLYSAID", + dt=dt, + datetime=None, + ).thenReturn((mock_offer, mock_sigs, mock_atc)) + + offer, sigs, atc = credentialing.Ipex(mock_client).offer( + "aid1", + "ERECIPIENT", + DummyAcdc(), + message="this matches", + applySaid="EAPPLYSAID", + dt=dt, + ) + + assert offer == mock_offer + assert sigs == mock_sigs + assert atc == mock_atc + + +def test_ipex_agree(): + from signify.app.clienting import SignifyClient + from signify.app.aiding import Identifiers + from signify.app.exchanging import Exchanges + + mock_client = mock(spec=SignifyClient, strict=True) + mock_ids = mock(spec=Identifiers, strict=True) + mock_excs = mock(spec=Exchanges, strict=True) + + dt = "2023-09-25T16:01:37.000000+00:00" + mock_hab = {"prefix": "a_prefix", "name": "aid1", "state": {"s": "1", "d": "ABCDEFG"}} + mock_agree = {} + mock_sigs = [] + mock_atc = "" + + expect(mock_client, times=1).identifiers().thenReturn(mock_ids) + expect(mock_ids, times=1).get("aid1").thenReturn(mock_hab) + expect(mock_client, times=1).exchanges().thenReturn(mock_excs) + expect(mock_excs, times=1).createExchangeMessage( + sender=mock_hab, + route="/ipex/agree", + payload={"m": "agreed"}, + embeds={}, + recipient="ERECIPIENT", + dig="EOFFERSAID", + dt=dt, + datetime=None, + ).thenReturn((mock_agree, mock_sigs, mock_atc)) + + agree, sigs, atc = credentialing.Ipex(mock_client).agree( + "aid1", + "ERECIPIENT", + "EOFFERSAID", + message="agreed", + dt=dt, + ) + + assert agree == mock_agree + assert sigs == mock_sigs + assert atc == mock_atc + + +def test_ipex_name_grant(): + from signify.app.clienting import SignifyClient + from signify.app.aiding import Identifiers + from signify.app.exchanging import Exchanges + + class DummyAcdc: + raw = b"ACDC" + + class DummyEvt: + raw = b"KERI" + + mock_client = mock(spec=SignifyClient, strict=True) + mock_ids = mock(spec=Identifiers, strict=True) + mock_excs = mock(spec=Exchanges, strict=True) + + dt = "2023-09-25T16:01:37.000000+00:00" + mock_hab = {"prefix": "a_prefix", "name": "aid1", "state": {"s": "1", "d": "ABCDEFG"}} + mock_grant = {} + mock_sigs = [] + mock_atc = "" + + expect(mock_client, times=1).identifiers().thenReturn(mock_ids) + expect(mock_ids, times=1).get("aid1").thenReturn(mock_hab) + expect(mock_client, times=1).exchanges().thenReturn(mock_excs) + expect(mock_excs, times=1).createExchangeMessage( + sender=mock_hab, + route="/ipex/grant", + payload={"m": "present", "i": "ERECIPIENT"}, + embeds={"acdc": b"ACDCattach-acdc", "iss": b"KERIattach-iss", "anc": b"KERIattach-anc"}, + recipient="ERECIPIENT", + dt=dt, + datetime=None, + dig="EAGREE123", + ).thenReturn((mock_grant, mock_sigs, mock_atc)) + + grant, sigs, atc = credentialing.Ipex(mock_client).grant( + name="aid1", + recipient="ERECIPIENT", + message="present", + acdc=DummyAcdc(), + iss=DummyEvt(), + anc=DummyEvt(), + acdcAttachment="attach-acdc", + issAttachment="attach-iss", + ancAttachment="attach-anc", + agreeSaid="EAGREE123", + dt=dt, + ) + + assert grant == mock_grant + assert sigs == mock_sigs + assert atc == mock_atc + + +def test_ipex_name_admit(): + from signify.app.clienting import SignifyClient + from signify.app.aiding import Identifiers + from signify.app.exchanging import Exchanges + + mock_client = mock(spec=SignifyClient, strict=True) + mock_ids = mock(spec=Identifiers, strict=True) + mock_excs = mock(spec=Exchanges, strict=True) + + dt = "2023-09-25T16:01:37.000000+00:00" + mock_hab = {"prefix": "a_prefix", "name": "aid1", "state": {"s": "1", "d": "ABCDEFG"}} + mock_admit = {} + mock_sigs = [] + mock_atc = "" + + expect(mock_client, times=1).identifiers().thenReturn(mock_ids) + expect(mock_ids, times=1).get("aid1").thenReturn(mock_hab) + expect(mock_client, times=1).exchanges().thenReturn(mock_excs) + expect(mock_excs, times=1).createExchangeMessage( + sender=mock_hab, + route="/ipex/admit", + payload={"m": ""}, + embeds=None, + recipient="ERECIPIENT", + dt=dt, + datetime=None, + dig="EGRANT123", + ).thenReturn((mock_admit, mock_sigs, mock_atc)) + + admit, sigs, atc = credentialing.Ipex(mock_client).admit( + name="aid1", + recipient="ERECIPIENT", + grantSaid="EGRANT123", + dt=dt, + ) + + assert admit == mock_admit + assert sigs == mock_sigs + assert atc == mock_atc + def test_ipex_admit(): from signify.app.clienting import SignifyClient mock_client = mock(spec=SignifyClient, strict=True) @@ -388,6 +924,7 @@ def test_ipex_admit(): embeds=None, recipient='ELI7pg979AdhmvrjDeam2eAO2SR5niCgnjAJXJHtJose', dt=dt, + datetime=None, dig=grant.said).thenReturn((mock_admit, mock_gsigs, mock_atc)) ipex = credentialing.Ipex(mock_client) # type: ignore @@ -404,6 +941,27 @@ def test_ipex_admit(): assert atc == mock_atc +def test_submit_apply(): + from signify.app.clienting import SignifyClient + from requests import Response + + mock_client = mock(spec=SignifyClient, strict=True) + mock_rep = mock(spec=Response, strict=True) + + expect(mock_rep).json().thenReturn(dict(b='c')) + + mock_apply = mock({'ked': dict(a='b')}) + mock_sigs = [] + recp = ["ELI7pg979AdhmvrjDeam2eAO2SR5niCgnjAJXJHtJose"] + + ipex = credentialing.Ipex(mock_client) # type: ignore + body = {'exn': {'a': 'b'}, 'sigs': [], 'rec': ['ELI7pg979AdhmvrjDeam2eAO2SR5niCgnjAJXJHtJose']} + expect(mock_client, times=1).post(f"/identifiers/aid1/ipex/apply", json=body).thenReturn(mock_rep) + rep = ipex.submitApply("aid1", exn=mock_apply, sigs=mock_sigs, recp=recp) + + assert rep == dict(b='c') + + def test_submit_admit(): from signify.app.clienting import SignifyClient @@ -427,6 +985,48 @@ def test_submit_admit(): assert rep == dict(b='c') +def test_submit_offer(): + from signify.app.clienting import SignifyClient + from requests import Response + + mock_client = mock(spec=SignifyClient, strict=True) + mock_rep = mock(spec=Response, strict=True) + + expect(mock_rep).json().thenReturn(dict(b='c')) + + mock_offer = mock({'ked': dict(a='b')}) + mock_sigs = [] + mock_end = "" + recp = ["ELI7pg979AdhmvrjDeam2eAO2SR5niCgnjAJXJHtJose"] + + ipex = credentialing.Ipex(mock_client) # type: ignore + body = {'exn': {'a': 'b'}, 'sigs': [], 'atc': '', 'rec': ['ELI7pg979AdhmvrjDeam2eAO2SR5niCgnjAJXJHtJose']} + expect(mock_client, times=1).post(f"/identifiers/aid1/ipex/offer", json=body).thenReturn(mock_rep) + rep = ipex.submitOffer("aid1", exn=mock_offer, sigs=mock_sigs, atc=mock_end, recp=recp) + + assert rep == dict(b='c') + + +def test_submit_agree(): + from signify.app.clienting import SignifyClient + from requests import Response + + mock_client = mock(spec=SignifyClient, strict=True) + mock_rep = mock(spec=Response, strict=True) + + expect(mock_rep).json().thenReturn(dict(b='c')) + + mock_agree = mock({'ked': dict(a='b')}) + mock_sigs = [] + recp = ["ELI7pg979AdhmvrjDeam2eAO2SR5niCgnjAJXJHtJose"] + + ipex = credentialing.Ipex(mock_client) # type: ignore + body = {'exn': {'a': 'b'}, 'sigs': [], 'rec': ['ELI7pg979AdhmvrjDeam2eAO2SR5niCgnjAJXJHtJose']} + expect(mock_client, times=1).post(f"/identifiers/aid1/ipex/agree", json=body).thenReturn(mock_rep) + rep = ipex.submitAgree("aid1", exn=mock_agree, sigs=mock_sigs, recp=recp) + + assert rep == dict(b='c') + def test_submit_grant(): from signify.app.clienting import SignifyClient diff --git a/tests/app/test_integration_helpers.py b/tests/app/test_integration_helpers.py index 340b5b0..c45c93a 100644 --- a/tests/app/test_integration_helpers.py +++ b/tests/app/test_integration_helpers.py @@ -147,7 +147,7 @@ def get(self, said): self.calls += 1 if self.calls == 1: raise HTTPError("exchange not ready") - return {"exn": {"r": "/multisig/exn", "a": {}}} + return {"exn": {"r": "/multisig/exn", "a": {}, "e": {"exn": {"r": "/ipex/grant"}}}} notifications = FakeNotifications() exchanges = FakeExchanges() @@ -169,6 +169,54 @@ def exchanges(self): assert exchanges.calls == 2 +def test_wait_for_exchange_message_selects_matching_embedded_route(monkeypatch): + monkeypatch.setattr(helpers.time, "sleep", lambda _: None) + + notes = [ + {"i": "note-2b", "a": {"r": "/multisig/exn", "d": "said-2b"}, "r": False}, + {"i": "note-2c", "a": {"r": "/multisig/exn", "d": "said-2c"}, "r": False}, + ] + + class FakeNotifications: + def __init__(self): + self.marked = [] + + def list(self): + return {"notes": notes} + + def mark(self, nid): + self.marked.append(nid) + return True + + class FakeExchanges: + def get(self, said): + if said == "said-2b": + return {"exn": {"r": "/multisig/exn", "a": {}, "e": {"exn": {"r": "/ipex/admit"}}}} + assert said == "said-2c" + return {"exn": {"r": "/multisig/exn", "a": {}, "e": {"exn": {"r": "/ipex/grant"}}}} + + notifications = FakeNotifications() + exchanges = FakeExchanges() + + class FakeClient: + def notifications(self): + return notifications + + def exchanges(self): + return exchanges + + returned_note, exchange = helpers.wait_for_exchange_message( + FakeClient(), + "/multisig/exn", + embedded_route="/ipex/grant", + timeout=1.0, + ) + + assert returned_note["i"] == "note-2c" + assert exchange["exn"]["e"]["exn"]["r"] == "/ipex/grant" + assert notifications.marked == ["note-2c"] + + def test_wait_for_contact_challenge_state_waits_for_authenticated_challenge(monkeypatch): monkeypatch.setattr(helpers.time, "sleep", lambda _: None) @@ -235,3 +283,291 @@ def notifications(self): assert note["i"] == "note-4" assert notifications.marked == ["note-4"] + + +def test_wait_for_notification_any_returns_first_available_client_note(monkeypatch): + monkeypatch.setattr(helpers.time, "sleep", lambda _: None) + + class FakeNotifications: + def __init__(self, notes): + self.notes = notes + self.marked = [] + + def list(self): + return {"notes": self.notes} + + def mark(self, nid): + self.marked.append(nid) + return True + + notifications_a = FakeNotifications( + [{"i": "note-a", "a": {"r": "/multisig/iss", "d": "ignored"}, "r": False}] + ) + notifications_b = FakeNotifications( + [{"i": "note-b", "a": {"r": "/exn/ipex/grant", "d": "grant-said"}, "r": False}] + ) + + class FakeClient: + def __init__(self, notifications): + self._notifications = notifications + + def notifications(self): + return self._notifications + + index, note = helpers.wait_for_notification_any( + [FakeClient(notifications_a), FakeClient(notifications_b)], + "/exn/ipex/grant", + timeout=1.0, + ) + + assert index == 1 + assert note["i"] == "note-b" + assert notifications_a.marked == [] + assert notifications_b.marked == ["note-b"] + + +def test_wait_for_exchange_waits_for_retrievable_exchange(monkeypatch): + monkeypatch.setattr(helpers.time, "sleep", lambda _: None) + + class FakeExchanges: + def __init__(self): + self.calls = 0 + + def get(self, said): + assert said == "said-2" + self.calls += 1 + if self.calls == 1: + raise HTTPError("exchange not ready") + return {"exn": {"r": "/multisig/exn", "a": {}}} + + exchanges = FakeExchanges() + + class FakeClient: + def exchanges(self): + return exchanges + + exchange = helpers.wait_for_exchange( + FakeClient(), + "said-2", + expected_route="/multisig/exn", + timeout=1.0, + ) + + assert exchange["exn"]["r"] == "/multisig/exn" + assert exchanges.calls == 2 + + +def test_send_multisig_credential_grant_returns_raw_operation_and_mirrors_peer_message( + monkeypatch, +): + captured_wait = {} + + def fake_wait_for_exchange_message(*args, **kwargs): + captured_wait["kwargs"] = kwargs + return "note", "exchange" + + monkeypatch.setattr(helpers, "wait_for_exchange_message", fake_wait_for_exchange_message) + monkeypatch.setattr( + helpers.app_signing, + "serialize", + lambda *args, **kwargs: b"serialized-acdc", + ) + messagize_calls = [] + + def fake_messagize(**kwargs): + messagize_calls.append(kwargs) + return bytearray(b"msg") + + monkeypatch.setattr(helpers.eventing, "messagize", fake_messagize) + monkeypatch.setattr(helpers.eventing, "SealEvent", lambda **kwargs: kwargs) + monkeypatch.setattr(helpers.csigning, "Siger", lambda qb64: qb64) + monkeypatch.setattr(helpers.coring, "Prefixer", lambda qb64: qb64) + monkeypatch.setattr(helpers.coring, "Seqner", lambda sn: sn) + monkeypatch.setattr(helpers.coring, "Saider", lambda qb64: qb64) + fresh_state = {"ee": {"s": "2", "d": "fresh-digest"}} + query_calls = [] + + monkeypatch.setattr( + helpers, + "query_key_state", + lambda *args, **kwargs: query_calls.append((args, kwargs)) or fresh_state, + ) + monkeypatch.setattr( + helpers, + "wait_for_operation", + lambda *args, **kwargs: pytest.fail("helper should not wait internally"), + ) + + raw_operation = {"done": False, "name": "grant-op"} + captured = {} + + class FakeIdentifiers: + def get(self, name): + mapping = { + "member-a": {"prefix": "member-prefix"}, + "group": { + "prefix": "group-prefix", + "state": {"ee": {"s": "1", "d": "stale-digest"}}, + }, + } + return mapping[name] + + class FakeIpex: + def grant(self, *args, **kwargs): + captured["grant"] = {"args": args, "kwargs": kwargs} + return {"d": "grant-said"}, ["sig-1"], "grant-atc" + + def submitGrant(self, name, **kwargs): + captured["submitGrant"] = {"name": name, **kwargs} + return raw_operation + + class FakeRegistries: + def serialize(self, iserder, anc): + captured["serialize"] = {"iserder": iserder, "anc": anc} + return b"serialized-iss" + + class FakeExchanges: + def send(self, name, topic, **kwargs): + captured["send"] = {"name": name, "topic": topic, **kwargs} + return {"done": False, "name": "peer-exn-op"} + + class FakeClient: + def identifiers(self): + return FakeIdentifiers() + + def ipex(self): + return FakeIpex() + + def registries(self): + return FakeRegistries() + + def exchanges(self): + return FakeExchanges() + + class FakeIserder: + pre = "issuer-prefix" + sn = 0 + said = "iss-said" + + class FakeAnc: + raw = b"anc-raw" + + timestamp = "2026-03-24T12:00:00.000000+00:00" + client = FakeClient() + result = helpers.send_multisig_credential_grant( + client, + local_member_name="member-a", + group_name="group", + other_member_prefixes=["member-b-prefix"], + recipient="holder-group-prefix", + creder={"d": "cred-said"}, + iserder=FakeIserder(), + anc=FakeAnc(), + sigs=["sig-anc"], + timestamp=timestamp, + is_initiator=False, + ) + + assert result == raw_operation + assert captured_wait["kwargs"]["embedded_route"] == "/ipex/grant" + assert len(query_calls) == 1 + assert query_calls[0][0][0] is client + assert query_calls[0][0][1] == "group-prefix" + assert captured["grant"]["kwargs"]["dt"] == timestamp + assert captured["grant"]["args"][0]["state"] == fresh_state + assert captured["submitGrant"]["name"] == "group" + assert captured["submitGrant"]["recp"] == ["holder-group-prefix"] + assert any(call.get("seal") == {"i": "group-prefix", "s": "2", "d": "fresh-digest"} for call in messagize_calls) + assert captured["send"]["name"] == "member-a" + assert captured["send"]["topic"] == "multisig" + assert captured["send"]["route"] == "/multisig/exn" + assert captured["send"]["recipients"] == ["member-b-prefix"] + + +def test_submit_multisig_admit_returns_raw_operation_and_mirrors_peer_message(monkeypatch): + messagize_calls = [] + + def fake_messagize(**kwargs): + messagize_calls.append(kwargs) + return bytearray(b"msg") + + monkeypatch.setattr(helpers.eventing, "messagize", fake_messagize) + monkeypatch.setattr(helpers.eventing, "SealEvent", lambda **kwargs: kwargs) + monkeypatch.setattr(helpers.csigning, "Siger", lambda qb64: qb64) + fresh_state = {"ee": {"s": "2", "d": "fresh-digest"}} + query_calls = [] + + monkeypatch.setattr( + helpers, + "query_key_state", + lambda *args, **kwargs: query_calls.append((args, kwargs)) or fresh_state, + ) + monkeypatch.setattr( + helpers, + "wait_for_operation", + lambda *args, **kwargs: pytest.fail("helper should not wait internally"), + ) + + raw_operation = {"done": False, "name": "admit-op"} + captured = {} + + class FakeIdentifiers: + def get(self, name): + mapping = { + "member-a": {"prefix": "member-prefix"}, + "group": { + "prefix": "group-prefix", + "state": {"ee": {"s": "1", "d": "stale-digest"}}, + }, + } + return mapping[name] + + class FakeIpex: + def admit(self, *args, **kwargs): + captured["admit"] = {"args": args, "kwargs": kwargs} + return {"d": "admit-said"}, ["sig-1"], "admit-atc" + + def submitAdmit(self, name, **kwargs): + captured["submitAdmit"] = {"name": name, **kwargs} + return raw_operation + + class FakeExchanges: + def send(self, name, topic, **kwargs): + captured["send"] = {"name": name, "topic": topic, **kwargs} + return {"done": False, "name": "peer-exn-op"} + + class FakeClient: + def identifiers(self): + return FakeIdentifiers() + + def ipex(self): + return FakeIpex() + + def exchanges(self): + return FakeExchanges() + + timestamp = "2026-03-24T12:00:00.000000+00:00" + client = FakeClient() + result = helpers.submit_multisig_admit( + client, + local_member_name="member-a", + group_name="group", + other_member_prefixes=["member-b-prefix"], + issuer_prefix="issuer-group-prefix", + grant_said="grant-note-said", + timestamp=timestamp, + ) + + assert result == raw_operation + assert len(query_calls) == 1 + assert query_calls[0][0][0] is client + assert query_calls[0][0][1] == "group-prefix" + assert captured["admit"]["args"][0]["state"] == fresh_state + assert captured["admit"]["args"][4] == timestamp + assert captured["submitAdmit"]["name"] == "group" + assert captured["submitAdmit"]["recp"] == ["issuer-group-prefix"] + assert any(call.get("seal") == {"i": "group-prefix", "s": "2", "d": "fresh-digest"} for call in messagize_calls) + assert captured["send"]["name"] == "member-a" + assert captured["send"]["topic"] == "multisig" + assert captured["send"]["route"] == "/multisig/exn" + assert captured["send"]["recipients"] == ["member-b-prefix"] diff --git a/tests/app/test_integration_topology.py b/tests/app/test_integration_topology.py index c91793b..b888394 100644 --- a/tests/app/test_integration_topology.py +++ b/tests/app/test_integration_topology.py @@ -3,7 +3,7 @@ from __future__ import annotations -from tests.integration.constants import SCHEMA_SAID, WITNESS_AIDS +from tests.integration.constants import QVI_SCHEMA_SAID, WITNESS_AIDS from tests.integration.topology import ( additional_schema_oobis, make_stack_topology, @@ -23,7 +23,7 @@ def test_make_stack_topology_builds_urls_and_oobis(tmp_path): assert topology.keria_agent_url == "http://127.0.0.1:5902" assert topology.keria_boot_url == "http://127.0.0.1:5903" assert topology.vlei_schema_url == "http://127.0.0.1:7723" - assert topology.schema_oobi == f"http://127.0.0.1:7723/oobi/{SCHEMA_SAID}" + assert topology.schema_oobi == f"http://127.0.0.1:7723/oobi/{QVI_SCHEMA_SAID}" assert topology.witness_oobis == [ f"http://127.0.0.1:5601/oobi/{WITNESS_AIDS[0]}/controller?name=Wan&tag=witness", f"http://127.0.0.1:5602/oobi/{WITNESS_AIDS[1]}/controller?name=Wil&tag=witness", diff --git a/tests/conftest.py b/tests/conftest.py index c8d91bc..f2c972b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,9 @@ -""" -Configure PyTest - -Use this module to configure pytest -https://docs.pytest.org/en/latest/pythonpath.html +"""Shared pytest fixtures and helpers for SignifyPy. +This file underpins both unit and integration coverage. The intent is to keep +test setup predictable and readable: lightweight factories for strict mocks, +opt-in time/randomness freezing, and a few KERIpy-backed helper classes for +tests that need lower-level habitat or credential primitives. """ import json from contextlib import contextmanager @@ -126,15 +126,10 @@ def factory(): @pytest.fixture() def mockHelpingNowUTC(monkeypatch): - """ - Replace nowUTC universally with fixed value for testing - """ + """Freeze `helping.nowUTC` at one value for deterministic time assertions.""" def mockNowUTC(): - """ - Use predetermined value for now (current time) - '2021-01-01T00:00:00.000000+00:00' - """ + """Return the fixed test timestamp used by UTC-based callers.""" return helping.fromIso8601("2021-01-01T00:00:00.000000+00:00") monkeypatch.setattr(helping, "nowUTC", mockNowUTC) @@ -142,15 +137,10 @@ def mockNowUTC(): @pytest.fixture() def mockHelpingNowIso8601(monkeypatch): - """ - Replace nowIso8601 universally with fixed value for testing - """ + """Freeze `helping.nowIso8601` for code that stamps exchange payloads.""" def mockNowIso8601(): - """ - Use predetermined value for now (current time) - '2021-01-01T00:00:00.000000+00:00' - """ + """Return the fixed ISO-8601 timestamp used by timestamped builders.""" return "2021-06-27T21:26:21.233257+00:00" monkeypatch.setattr(helping, "nowIso8601", mockNowIso8601) @@ -158,7 +148,7 @@ def mockNowIso8601(): @pytest.fixture() def mockCoringRandomNonce(monkeypatch): - """ Replay randomNonce with fixed value for testing""" + """Freeze `coring.randomNonce` for deterministic registry/credential payloads.""" def mockRandomNonce(): return "A9XfpxIl1LcIkMhUSCCC8fgvkuX8gG9xK3SM-S8a8Y_U" @@ -167,17 +157,11 @@ def mockRandomNonce(): @dataclass class IcpCfg: - """ - Configuration for inception - - Constructor arguments: - :param name: str - Name of the AID - :param icount: int - Signing key count for the AID - :param isith: str - Signing threshold for the AID - :param ncount: int - Rotation key count for the AID - :param nsith: str - Rotation threshold for the AID - :param toad: int - Threshold of accountable duplicity for the AID - :param wits: List[str] - List of witness AIDs for the AID + """Compact input bundle for helper-driven AID inception in tests. + + These defaults describe the smallest witnessed single-sig AID that still + exercises the KERIpy controller stack honestly. Tests override fields only + when they are specifically about inception shape. """ name: str = "test_aid" icount: int = 1 @@ -191,20 +175,12 @@ class HabbingHelpers: @staticmethod @contextmanager def openHab(name='test', base='', salt=None, temp=True, cf=None, **kwa): - """ - Context manager wrapper for Hab instance. - Defaults to temporary resources - Context 'with' statements call .close on exit of 'with' block + """Open one temporary habitat pair for lower-level controller tests. - Parameters: - name(str): name of habitat to create - base(str): the name used for shared resources i.e. Baser and Keeper The habitat specific config file will be - in base/name - salt(bytes): passed to habitat to use for inception raw salt not qb64 - temp(bool): indicates if this uses temporary databases - cf(Configer): optional configer for loading configuration data - TODO: replace this openHab fixture with one from KERIpy once https://github.com/WebOfTrust/keripy/pull/1078 is merged. - this copy was needed in order to pass cf to Habery.makeHab() since the **kwa is not unpacking the cf arg. + This wrapper exists because some SignifyPy tests still need direct + KERIpy habitat access, and upstream helpers have not always exposed the + `cf` wiring those tests need. It should be read as compatibility test + scaffolding, not as a SignifyPy public API pattern. """ salt = core.Salter(raw=salt).qb64 @@ -235,7 +211,7 @@ def habery_doers(hby: habbing.Habery): @staticmethod def resolve_wit_oobi(doist: doing.Doist, wit_deeds: List[Doer], hby: habbing.Habery, oobi: str, alias: str = None): - """Resolve an OOBI depending on a given witness for a given Habery.""" + """Resolve one OOBI through the witness-driven KERIpy test harness.""" obr = basing.OobiRecord(date=helping.nowIso8601()) if alias is not None: obr.oobialias = alias @@ -250,11 +226,7 @@ def resolve_wit_oobi(doist: doing.Doist, wit_deeds: List[Doer], hby: habbing.Hab @staticmethod def incept_aid(doist: doing.Doist, wit_deeds: List[Doer], hby_deeds: List[Doer], hby: habbing.Habery, icp_cfg: IcpCfg, wit_rcptr: agenting.WitnessReceiptor): - """ - Incept an AID in the given Habery using the given inception configuration. - - Does not yet support delegation or multisig. - """ + """Incept one non-delegated, non-multisig AID and wait for receipts.""" # perform inception hab = hby.makeHab(name=icp_cfg.name, isith=icp_cfg.isith, icount=icp_cfg.icount, toad=icp_cfg.toad, wits=icp_cfg.wits) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 2ca5042..fb288a9 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -438,19 +438,32 @@ def factory(**kwargs): @pytest.fixture(scope="session") def shared_live_stack(tmp_path_factory: pytest.TempPathFactory, request: pytest.FixtureRequest): - """Launch one live stack per worker session.""" + """Launch one live stack per worker session. + + This is the default cost/performance tradeoff for integration work: each + xdist worker gets one deployment to reuse across many tests, while each + test still creates fresh agents and controllers on top of that deployment. + """ yield from _stack_fixture(tmp_path_factory, request, mode="shared") @pytest.fixture def isolated_live_stack(tmp_path_factory: pytest.TempPathFactory, request: pytest.FixtureRequest): - """Launch one fully isolated live stack per test.""" + """Launch one fully isolated live stack per test. + + Reach for this only when the test is about runtime isolation itself, or + when shared-stack reuse would make the behavior under test ambiguous. + """ yield from _stack_fixture(tmp_path_factory, request, mode="isolated") @pytest.fixture(scope="session") def live_stack(shared_live_stack): - """Compatibility alias for the shared-per-worker live stack.""" + """Compatibility alias for the shared-per-worker live stack. + + Older tests still refer to `live_stack`; newer ones should usually depend + on `client_factory` instead so the actor boundary stays explicit. + """ return shared_live_stack diff --git a/tests/integration/constants.py b/tests/integration/constants.py index 7ca8487..5bf44d9 100644 --- a/tests/integration/constants.py +++ b/tests/integration/constants.py @@ -5,11 +5,13 @@ that do not change when the harness runs in parallel. """ -SCHEMA_SAID = "EBfdlu8R27Fbx-ehrqwImnK-8Cm79sqbAQ4MmvEAYqao" +QVI_SCHEMA_SAID = "EBfdlu8R27Fbx-ehrqwImnK-8Cm79sqbAQ4MmvEAYqao" ADDITIONAL_SCHEMA_OOBI_SAIDS = { "legal-entity": "ENPXp1vQzRF6JwIuS-mp2U8Uf1MoADoP_GqQ62VsDZWY", "ecr-auth": "EH6ekLjSr8V32WyFbGe1zXjTzFs9PkTYmupJ9H65O14g", "ecr": "EEy9PkikFcANV1l7EHukCeXqrzT1hNZjGlUk7wuMO5jw", + "oor-auth": "EKA57bKBKxr_kN7iN5i7lMUxpMG-s19dRcmov1iDxz-E", + "oor": "EBNaNu-M9P5cgrnfl2Fvymy4E_jvxxyjb70PRtiANlJy", } WITNESS_AIDS = [ diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index d4f8fec..296fc39 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -27,7 +27,8 @@ from signify.app.clienting import SignifyClient from tests.integration.constants import ( - SCHEMA_SAID, + ADDITIONAL_SCHEMA_OOBI_SAIDS, + QVI_SCHEMA_SAID, TEST_WITNESS_AIDS, WITNESS_AIDS, ) @@ -127,9 +128,25 @@ def witness_oobi_by_aid(client: SignifyClient) -> dict[str, str]: return dict(zip(WITNESS_AIDS, _require_live_stack(client)["witness_oobis"])) -def schema_oobi(client: SignifyClient) -> str: - """Return the current stack's primary schema OOBI.""" - return _require_live_stack(client)["schema_oobi"] +def schema_oobis_by_said(client: SignifyClient) -> dict[str, str]: + """Return all stack-local schema OOBIs keyed by schema SAID.""" + live_stack = _require_live_stack(client) + schema_oobis = {QVI_SCHEMA_SAID: live_stack["schema_oobi"]} + schema_oobis.update( + { + ADDITIONAL_SCHEMA_OOBI_SAIDS[alias]: oobi + for alias, oobi in live_stack["additional_schema_oobis"].items() + } + ) + return schema_oobis + + +def schema_oobi(client: SignifyClient, schema_said: str) -> str: + """Return one stack-local schema OOBI by schema SAID.""" + try: + return schema_oobis_by_said(client)[schema_said] + except KeyError as err: + raise ValueError(f"unknown schema_said={schema_said}") from err def additional_schema_oobis(client: SignifyClient) -> dict[str, str]: @@ -137,9 +154,18 @@ def additional_schema_oobis(client: SignifyClient) -> dict[str, str]: return dict(_require_live_stack(client)["additional_schema_oobis"]) -def resolve_schema_oobi(client: SignifyClient, *, alias: str = "schema") -> dict: - """Resolve the current stack's primary schema OOBI.""" - return resolve_oobi(client, schema_oobi(client), alias=alias) +def resolve_schema_oobi( + client: SignifyClient, + schema_said: str, + *, + alias: str | None = None, +) -> dict: + """Resolve one stack-local schema OOBI identified explicitly by schema SAID.""" + return resolve_oobi( + client, + schema_oobi(client, schema_said), + alias=alias or f"schema-{schema_said[:8]}", + ) def _format_poll_value(value) -> str: @@ -254,6 +280,39 @@ def wait_for_notification( return note +def wait_for_notification_any( + clients: list[SignifyClient], + route: str, + *, + timeout: float = 120.0, + mark_read: bool = True, +) -> tuple[int, dict]: + """Wait until any client exposes one unread notification on `route`. + + This is useful when multisig workflows only need one participant to surface + the shared message SAID before the rest of the wave can proceed. + """ + + def _fetch(): + for index, client in enumerate(clients): + notes = _matching_unread_notes(client, route) + if notes: + return index, notes[-1] + return None + + result = poll_until( + _fetch, + ready=lambda value: value is not None, + timeout=timeout, + interval=POLL_INTERVAL, + describe=f"notification route {route} on any client", + ) + index, note = result + if mark_read: + clients[index].notifications().mark(note["i"]) + return index, note + + def notification_routes(client: SignifyClient) -> list[str]: """Return current notification routes for read-path assertions.""" return [note["a"].get("r") for note in client.notifications().list()["notes"]] @@ -325,6 +384,44 @@ def wait_for_credential(client: SignifyClient, said: str, *, timeout: float = 12 ) +def wait_for_filtered_credential( + client: SignifyClient, + said: str, + *, + filter: dict, + timeout: float = 120.0, +) -> dict: + """Poll one filtered credential view until the expected SAID appears.""" + return poll_until( + lambda: next( + ( + credential + for credential in client.credentials().list(filter=filter) + if credential["sad"]["d"] == said + ), + None, + ), + ready=lambda credential: credential is not None, + timeout=timeout, + interval=HEAVY_POLL_INTERVAL, + describe=f"credential {said} with filter {filter}", + ) + + +def wait_for_multisig_received_credential( + client_a: SignifyClient, + client_b: SignifyClient, + said: str, + *, + timeout: float = 120.0, +) -> tuple[dict, dict]: + """Wait until both multisig members can read the same received credential.""" + return ( + wait_for_credential(client_a, said, timeout=timeout), + wait_for_credential(client_b, said, timeout=timeout), + ) + + def wait_for_multisig_request( client: SignifyClient, route: str, @@ -356,6 +453,7 @@ def wait_for_exchange_message( client: SignifyClient, route: str, *, + embedded_route: str | None = None, timeout: float = 120.0, ) -> tuple[dict, dict]: """Wait for a notification and the corresponding stored exchange payload. @@ -364,16 +462,63 @@ def wait_for_exchange_message( the notification identifies *which* exchange matters, but the stronger readiness condition is that the exchange itself is retrievable. """ - note = wait_for_notification(client, route, timeout=timeout) + def _fetch(): + notes = _matching_unread_notes(client, route) + for note in reversed(notes): + try: + exchange = client.exchanges().get(note["a"]["d"]) + except HTTPError: + continue + + if exchange["exn"]["r"] != route: + continue + + if embedded_route is not None: + outer = exchange["exn"] + embeds = outer.get("e", {}) + embedded = embeds.get("exn") + if embedded is None or embedded.get("r") != embedded_route: + continue + + return note, exchange + + return None + + describe = f"exchange message route {route}" + if embedded_route is not None: + describe += f" with embedded route {embedded_route}" + + note, exchange = poll_until( + _fetch, + ready=lambda value: value is not None, + timeout=timeout, + interval=POLL_INTERVAL, + describe=describe, + ) + + client.notifications().mark(note["i"]) + return note, exchange + + +def wait_for_exchange( + client: SignifyClient, + said: str, + *, + expected_route: str | None = None, + timeout: float = 120.0, +) -> dict: + """Poll until one stored exchange becomes retrievable by SAID.""" exchange = poll_until( - lambda: client.exchanges().get(note["a"]["d"]), - ready=lambda exchange: exchange["exn"]["r"] == route, + lambda: client.exchanges().get(said), + ready=lambda exchange: ( + expected_route is None or exchange["exn"]["r"] == expected_route + ), timeout=timeout, interval=POLL_INTERVAL, - describe=f"exchange payload for route {route}", + describe=f"exchange payload {said}", retry_exceptions=(HTTPError,), ) - return note, exchange + return exchange def wait_for_issued_credential( @@ -388,7 +533,7 @@ def wait_for_issued_credential( lambda: next( ( credential - for credential in client.credentials().list(filtr={"-i": issuer_prefix}) + for credential in client.credentials().list(filter={"-i": issuer_prefix}) if credential["sad"]["d"] == said ), None, @@ -1144,7 +1289,7 @@ def expose_multisig_agent_oobi( member_b_name: str, group_name: str, ) -> str: - """Expose a multisig agent OOBI after both members publish end-role replies. + """Expose a resolvable multisig OOBI after both members publish end-role replies. Workflow substance: 1. Both members publish the group's agent-role authorization replies. @@ -1155,6 +1300,9 @@ def expose_multisig_agent_oobi( EID set. 4. Each member waits for a non-empty group agent OOBI and both answers are compared to confirm convergence on one publication route. + 5. Return the base multisig OOBI, not the raw `/agent/...` route. This + mirrors the SignifyTS multisig flows, which resolve the group OOBI + derived from the agent publication path. Important contract detail: Once an embedded `rpy` is already locally approved, the peer echo may be @@ -1188,17 +1336,17 @@ def expose_multisig_agent_oobi( wait_for_operation(client_b, operation) wait_for_group_agent_endroles(client_a, group_name, expected_eids=expected_eids_a) wait_for_group_agent_endroles(client_b, group_name, expected_eids=expected_eids_a) - oobi_a = wait_for_identifier_oobi(client_a, group_name, role="agent")[0] - oobi_b = wait_for_identifier_oobi(client_b, group_name, role="agent")[0] - assert oobi_a == oobi_b - return oobi_a + agent_oobi_a = wait_for_identifier_oobi(client_a, group_name, role="agent")[0] + agent_oobi_b = wait_for_identifier_oobi(client_b, group_name, role="agent")[0] + assert agent_oobi_a == agent_oobi_b + return agent_oobi_a.split("/agent/")[0] def expose_multisig_agent_oobi_n( participants: list[tuple[SignifyClient, str]], group_name: str, ) -> str: - """Expose a multisig agent OOBI for an ordered participant list. + """Expose a resolvable multisig OOBI for an ordered participant list. This is the N-party analogue of `expose_multisig_agent_oobi(...)`: publish the required group end-role replies on every member, wait for multisig state convergence, @@ -1241,7 +1389,7 @@ def expose_multisig_agent_oobi_n( oobis.append(wait_for_identifier_oobi(client, group_name, role="agent")[0]) assert len(set(oobis)) == 1 - return oobis[0] + return oobis[0].split("/agent/")[0] def start_multisig_rotation( @@ -1605,25 +1753,23 @@ def issue_credential( registry_name: str, recipient: str, data: dict, - schema: str = SCHEMA_SAID, + schema: str = QVI_SCHEMA_SAID, ): """Issue a credential through SignifyPy and wait for the issuance operation. This helper owns the "issue but do not yet transport" part of the credential flow. Transport to the holder still happens later through IPEX. """ - issuer_hab = client.identifiers().get(issuer_name) - registry = client.registries().get(issuer_name, registry_name) - creder, iserder, anc, sigs, operation = client.credentials().create( - issuer_hab, - registry=registry, + result = client.credentials().issue( + issuer_name, + registry_name, data=data, schema=schema, recipient=recipient, timestamp=helping.nowIso8601(), ) - wait_for_operation(client, operation) - return creder, iserder, anc, sigs + wait_for_operation(client, result.op()) + return result.acdc, result.iss, result.anc, result.sigs def revoke_credential( @@ -1634,13 +1780,13 @@ def revoke_credential( timestamp: str | None = None, ): """Revoke one single-sig credential and wait for the local revoke operation.""" - rserder, anc, sigs, operation = client.credentials().revoke( + result = client.credentials().revoke( issuer_name, credential_said, timestamp=timestamp, ) - wait_for_operation(client, operation) - return rserder, anc, sigs + wait_for_operation(client, result.op()) + return result.rev, result.anc, result.sigs def send_credential_grant( @@ -1652,7 +1798,8 @@ def send_credential_grant( iserder, anc, sigs: list[str], -) -> None: + agree_said: str | None = None, +) -> dict | None: """Create and submit the IPEX grant message for an issued credential. The grant packages the credential, the issuance event, and the anchoring @@ -1675,9 +1822,13 @@ def send_credential_grant( serder=anc, sigers=[csigning.Siger(qb64=sig) for sig in sigs], ), + agree=agree_said, dt=helping.nowIso8601(), ) - client.ipex().submitGrant(issuer_name, exn=grant, sigs=grant_sigs, atc=atc, recp=[recipient]) + result = client.ipex().submitGrant(issuer_name, exn=grant, sigs=grant_sigs, atc=atc, recp=[recipient]) + if isinstance(result, dict) and "done" in result: + return wait_for_operation(client, result) + return result def create_multisig_registry( @@ -1762,7 +1913,9 @@ def issue_multisig_credential( registry_name: str, recipient: str, data: dict, - schema: str = SCHEMA_SAID, + schema: str = QVI_SCHEMA_SAID, + edges: dict | None = None, + rules: dict | None = None, timestamp: str | None = None, is_initiator: bool = False, request: list[dict] | None = None, @@ -1782,7 +1935,6 @@ def issue_multisig_credential( local_member = client.identifiers().get(local_member_name) group_hab = client.identifiers().get(group_name) - registry = client.registries().get(group_name, registry_name) if request is not None: # As with `/multisig/vcp`, the follower path is only correct if it # approves the stored proposal instead of reconstructing one locally. @@ -1800,14 +1952,21 @@ def issue_multisig_credential( sigs=sigs, ).json() else: - creder, iserder, anc, sigs, operation = client.credentials().create( - group_hab, - registry=registry, + result = client.credentials().issue( + group_name, + registry_name, data=data, schema=schema, recipient=recipient, + edges=edges, + rules=rules, timestamp=timestamp or helping.nowIso8601(), ) + creder = result.acdc + iserder = result.iss + anc = result.anc + sigs = result.sigs + operation = result.op() client.exchanges().send( local_member_name, "multisig", @@ -1858,11 +2017,15 @@ def revoke_multisig_credential( group_hab = client.identifiers().get(group_name) # Revocation still begins with the local wrapper call; the regression # assertion is that all members converge on one revoke event and TEL state. - rserder, anc, sigs, operation = client.credentials().revoke( + result = client.credentials().revoke( group_name, credential_said, timestamp=timestamp, ) + rserder = result.rev + anc = result.anc + sigs = result.sigs + operation = result.op() client.exchanges().send( local_member_name, "multisig", @@ -1878,6 +2041,13 @@ def revoke_multisig_credential( return rserder, anc, sigs, operation, request +def _fresh_group_hab(client: SignifyClient, group_name: str) -> dict: + """Return one group habitat view with freshly queried key state.""" + group_hab = dict(client.identifiers().get(group_name)) + group_hab["state"] = query_key_state(client, group_hab["prefix"]) + return group_hab + + def send_multisig_credential_grant( client: SignifyClient, *, @@ -1889,19 +2059,19 @@ def send_multisig_credential_grant( iserder, anc, sigs: list[str], + timestamp: str, is_initiator: bool = False, ) -> dict: """Submit a multisig grant and mirror it to peer members via `/multisig/exn`. - The first member submits the real IPEX grant to the holder. The extra - `/multisig/exn` wrapper exists so peer members can record and approve that - exact grant message as part of the shared multisig workflow. + Callers must provide one shared timestamp for the whole grant wave so every + member computes the same inner grant SAID. """ if not is_initiator: - wait_for_exchange_message(client, "/multisig/exn") + wait_for_exchange_message(client, "/multisig/exn", embedded_route="/ipex/grant") local_member = client.identifiers().get(local_member_name) - group_hab = client.identifiers().get(group_name) + group_hab = _fresh_group_hab(client, group_name) prefixer = coring.Prefixer(qb64=iserder.pre) seqner = coring.Seqner(sn=iserder.sn) grant, grant_sigs, atc = client.ipex().grant( @@ -1910,11 +2080,8 @@ def send_multisig_credential_grant( message="", acdc=app_signing.serialize(creder, prefixer, seqner, coring.Saider(qb64=iserder.said)), iss=client.registries().serialize(iserder, anc), - anc=eventing.messagize( - serder=anc, - sigers=[csigning.Siger(qb64=sig) for sig in sigs], - ), - dt=helping.nowIso8601(), + anc=_messagize(anc, sigs), + dt=timestamp, ) result = client.ipex().submitGrant(group_name, exn=grant, sigs=grant_sigs, atc=atc, recp=[recipient]) seal = eventing.SealEvent( @@ -1928,22 +2095,15 @@ def send_multisig_credential_grant( seal=seal, ) grant_ims.extend(atc.encode("utf-8")) - exn, exn_sigs, exn_atc = client.exchanges().createExchangeMessage( + client.exchanges().send( + local_member_name, + "multisig", sender=local_member, route="/multisig/exn", payload=dict(gid=group_hab["prefix"]), embeds=dict(exn=grant_ims), - ) - client.exchanges().sendFromEvents( - local_member_name, - "multisig", - exn=exn, - sigs=exn_sigs, - atc=exn_atc, recipients=other_member_prefixes, ) - if isinstance(result, dict) and "done" in result: - return wait_for_operation(client, result) return result @@ -1953,7 +2113,7 @@ def submit_admit( holder_name: str, issuer_prefix: str, notification: dict, -) -> None: +) -> dict | None: """Submit an IPEX admit in response to a previously received grant notification. This is the holder-side acknowledgement step in the grant/admit @@ -1970,7 +2130,16 @@ def submit_admit( issuer_prefix, helping.nowIso8601(), ) - client.ipex().submitAdmit(holder_name, exn=admit, sigs=sigs, atc=atc, recp=[issuer_prefix]) + result = client.ipex().submitAdmit( + holder_name, + exn=admit, + sigs=sigs, + atc=atc, + recp=[issuer_prefix], + ) + if isinstance(result, dict) and "done" in result: + return wait_for_operation(client, result) + return result def submit_multisig_admit( @@ -1980,22 +2149,22 @@ def submit_multisig_admit( group_name: str, other_member_prefixes: list[str], issuer_prefix: str, - notification: dict, + grant_said: str, + timestamp: str, ) -> dict: """Submit a multisig admit and mirror it to peer members via `/multisig/exn`. - This mirrors `submit_admit(...)` for group workflows: submit the admit for - the shared group AID, then forward the exact admit message to peer members - so all participants observe the same group-side exchange. + Callers must provide one shared timestamp for the whole admit wave so every + member computes the same inner admit SAID. """ local_member = client.identifiers().get(local_member_name) - group_hab = client.identifiers().get(group_name) + group_hab = _fresh_group_hab(client, group_name) admit, sigs, atc = client.ipex().admit( group_hab, "", - notification["a"]["d"], + grant_said, issuer_prefix, - helping.nowIso8601(), + timestamp, ) result = client.ipex().submitAdmit(group_name, exn=admit, sigs=sigs, atc=atc, recp=[issuer_prefix]) seal = eventing.SealEvent( @@ -2009,20 +2178,13 @@ def submit_multisig_admit( seal=seal, ) admit_ims.extend(atc.encode("utf-8")) - exn, exn_sigs, exn_atc = client.exchanges().createExchangeMessage( + client.exchanges().send( + local_member_name, + "multisig", sender=local_member, route="/multisig/exn", payload=dict(gid=group_hab["prefix"]), embeds=dict(exn=admit_ims), - ) - client.exchanges().sendFromEvents( - local_member_name, - "multisig", - exn=exn, - sigs=exn_sigs, - atc=exn_atc, recipients=other_member_prefixes, ) - if isinstance(result, dict) and "done" in result: - return wait_for_operation(client, result) return result diff --git a/tests/integration/test_credentials.py b/tests/integration/test_credentials.py index ee7d1d5..dfd0f85 100644 --- a/tests/integration/test_credentials.py +++ b/tests/integration/test_credentials.py @@ -1,10 +1,18 @@ -"""Live credential presentation coverage for the single-sig grant/admit path.""" +"""Live single-sig credential and IPEX workflow coverage. + +These tests are intentionally written as end-to-end protocol narratives rather +than tiny isolated assertions. The important maintainer question here is not +just "did one method return?" but "did the full issuance, exchange, and +storage workflow converge in the same shape SignifyTS and KERIA expect?" +""" from __future__ import annotations import pytest +from keri.core import coring +from requests import HTTPError -from .constants import SCHEMA_SAID, TEST_WITNESS_AIDS +from .constants import ADDITIONAL_SCHEMA_OOBI_SAIDS, QVI_SCHEMA_SAID, TEST_WITNESS_AIDS from .helpers import ( alias, create_identifier, @@ -20,11 +28,84 @@ wait_for_credential, wait_for_credential_state, wait_for_notification, + wait_for_operation, ) pytestmark = pytest.mark.integration +LE_USAGE_DISCLAIMER = ( + "Usage of a valid, unexpired, and non-revoked vLEI Credential, as defined " + "in the associated Ecosystem Governance Framework, does not assert that " + "the Legal Entity is trustworthy, honest, reputable in its business " + "dealings, safe to do business with, or compliant with any laws or that " + "an implied or expressly intended purpose will be fulfilled." +) + +LE_ISSUANCE_DISCLAIMER = ( + "All information in a valid, unexpired, and non-revoked vLEI Credential, " + "as defined in the associated Ecosystem Governance Framework, is accurate " + "as of the date the validation process was complete. The vLEI Credential " + "has been issued to the legal entity or person named in the vLEI " + "Credential as the subject; and the qualified vLEI Issuer exercised " + "reasonable care to perform the validation process set forth in the vLEI " + "Ecosystem Governance Framework." +) + + +def test_credential_query_filter_contract(client_factory): + """Mirror the TS credential-list filter contract against a real agent. + + The maintained API surface is not just "list exists"; it needs to honor + the indexed issuer/schema/subject filters and their combinations in the + same workflow shape SignifyTS locks down. + """ + issuer_client = client_factory() + holder_client = client_factory() + issuer_name = alias("issuer") + holder_name = alias("holder") + registry_name = alias("registry") + + issuer = create_identifier(issuer_client, issuer_name, wits=TEST_WITNESS_AIDS) + holder = create_identifier(holder_client, holder_name, wits=TEST_WITNESS_AIDS) + exchange_agent_oobis(issuer_client, issuer_name, holder_client, holder_name) + resolve_schema_oobi(issuer_client, QVI_SCHEMA_SAID) + resolve_schema_oobi(holder_client, QVI_SCHEMA_SAID) + + _, registry = create_registry(issuer_client, issuer_name, registry_name) + creder, _, _, _ = issue_credential( + issuer_client, + issuer_name=issuer_name, + registry_name=registry_name, + recipient=holder["prefix"], + data={"LEI": "5493001KJTIIGC8Y1R17"}, + ) + + all_credentials = issuer_client.credentials().list() + issuer_filtered = issuer_client.credentials().list(filter={"-i": issuer["prefix"]}) + schema_filtered = issuer_client.credentials().list(filter={"-s": QVI_SCHEMA_SAID}) + subject_filtered = issuer_client.credentials().list(filter={"-a-i": holder["prefix"]}) + combined_filtered = issuer_client.credentials().list( + filter={"-i": issuer["prefix"], "-s": QVI_SCHEMA_SAID, "-a-i": holder["prefix"]} + ) + missing_filtered = issuer_client.credentials().list( + filter={"-i": "missing-issuer", "-s": QVI_SCHEMA_SAID, "-a-i": holder["prefix"]} + ) + state = issuer_client.credentials().state(registry["regk"], creder.said) + + assert len(all_credentials) == 1 + assert len(issuer_filtered) == 1 + assert len(schema_filtered) == 1 + assert len(subject_filtered) == 1 + assert len(combined_filtered) == 1 + assert len(missing_filtered) == 0 + assert issuer_filtered[0]["sad"]["d"] == creder.said + assert schema_filtered[0]["sad"]["s"] == QVI_SCHEMA_SAID + assert subject_filtered[0]["sad"]["a"]["i"] == holder["prefix"] + assert combined_filtered[0]["sad"]["d"] == creder.said + assert state["et"] == "iss" + assert state["s"] == "0" + def test_credential_presentation_grant_admit(client_factory): """Cover the single-sig IPEX grant/admit happy path end to end. @@ -52,8 +133,8 @@ def test_credential_presentation_grant_admit(client_factory): issuer = create_identifier(issuer_client, issuer_name, wits=TEST_WITNESS_AIDS) holder = create_identifier(holder_client, holder_name, wits=TEST_WITNESS_AIDS) exchange_agent_oobis(issuer_client, issuer_name, holder_client, holder_name) - resolve_schema_oobi(issuer_client) - resolve_schema_oobi(holder_client) + resolve_schema_oobi(issuer_client, QVI_SCHEMA_SAID) + resolve_schema_oobi(holder_client, QVI_SCHEMA_SAID) _, registry = create_registry(issuer_client, issuer_name, registry_name) assert registry["name"] == registry_name @@ -86,18 +167,414 @@ def test_credential_presentation_grant_admit(client_factory): admit_note = wait_for_notification(issuer_client, "/exn/ipex/admit") received = wait_for_credential(holder_client, creder.said) + fetched = holder_client.credentials().get(creder.said) + fetched_cesr = holder_client.credentials().get(creder.said, includeCESR=True) exported = holder_client.credentials().export(creder.said) - received_for_holder = holder_client.credentials().list(filtr={"-a-i": holder["prefix"]}) + received_for_holder = holder_client.credentials().list(filter={"-a-i": holder["prefix"]}) assert received["sad"]["d"] == creder.said assert received["sad"]["i"] == issuer["prefix"] assert received["sad"]["a"]["i"] == holder["prefix"] - assert received["sad"]["s"] == SCHEMA_SAID + assert received["sad"]["s"] == QVI_SCHEMA_SAID + assert fetched["sad"]["d"] == creder.said + assert fetched["sad"]["a"]["i"] == holder["prefix"] assert admit_note["a"]["r"] == "/exn/ipex/admit" assert any(credential["sad"]["d"] == creder.said for credential in received_for_holder) + assert fetched_cesr assert exported +def test_ipex_apply_offer_agree_grant_admit(client_factory): + """Prove the full single-sig IPEX conversation path, not just presentation. + + This is the missing early-conversation parity slice relative to SignifyTS: + a verifier applies, the holder offers, the verifier agrees, the holder + grants, and the verifier admits and stores the credential. + + Mental model: + - issuer -> holder bootstraps a real stored credential + - verifier -> holder starts a request conversation for that credential + - each later message points back to the prior message SAID + - only the final admit should cause the verifier to store the credential + """ + issuer_client = client_factory() + holder_client = client_factory() + verifier_client = client_factory() + issuer_name = alias("issuer") + holder_name = alias("holder") + verifier_name = alias("verifier") + registry_name = alias("registry") + + issuer = create_identifier(issuer_client, issuer_name, wits=TEST_WITNESS_AIDS) + holder = create_identifier(holder_client, holder_name, wits=TEST_WITNESS_AIDS) + verifier = create_identifier(verifier_client, verifier_name, wits=TEST_WITNESS_AIDS) + + exchange_agent_oobis(issuer_client, issuer_name, holder_client, holder_name) + exchange_agent_oobis(holder_client, holder_name, verifier_client, verifier_name) + resolve_schema_oobi(issuer_client, QVI_SCHEMA_SAID) + resolve_schema_oobi(holder_client, QVI_SCHEMA_SAID) + resolve_schema_oobi(verifier_client, QVI_SCHEMA_SAID) + + # Stage 1: bootstrap a real credential onto the holder so the later offer + # can reference a stored credential instead of a synthetic payload. + create_registry(issuer_client, issuer_name, registry_name) + creder, iserder, anc, sigs = issue_credential( + issuer_client, + issuer_name=issuer_name, + registry_name=registry_name, + recipient=holder["prefix"], + data={"LEI": "5493001KJTIIGC8Y1R17"}, + ) + send_credential_grant( + issuer_client, + issuer_name=issuer_name, + recipient=holder["prefix"], + creder=creder, + iserder=iserder, + anc=anc, + sigs=sigs, + ) + + initial_grant_note = wait_for_notification(holder_client, "/exn/ipex/grant") + submit_admit( + holder_client, + holder_name=holder_name, + issuer_prefix=issuer["prefix"], + notification=initial_grant_note, + ) + wait_for_notification(issuer_client, "/exn/ipex/admit") + wait_for_credential(holder_client, creder.said) + + # Stage 2: verifier asks the holder for a credential matching the target + # schema plus subject attributes. + apply, apply_sigs, _ = verifier_client.ipex().apply( + verifier_name, + holder["prefix"], + QVI_SCHEMA_SAID, + attributes={"LEI": "5493001KJTIIGC8Y1R17"}, + dt="2026-03-25T00:00:00.000000+00:00", + ) + wait_for_operation( + verifier_client, + verifier_client.ipex().submitApply(verifier_name, apply, apply_sigs, [holder["prefix"]]), + ) + + holder_apply_note = wait_for_notification(holder_client, "/exn/ipex/apply") + apply_exchange = holder_client.exchanges().get(holder_apply_note["a"]["d"]) + apply_said = apply_exchange["exn"]["d"] + + filter_kwargs = {"-s": apply_exchange["exn"]["a"]["s"]} + for key, value in apply_exchange["exn"]["a"]["a"].items(): + filter_kwargs[f"-a-{key}"] = value + matching_credentials = holder_client.credentials().list(filter=filter_kwargs) + + assert len(matching_credentials) == 1 + assert matching_credentials[0]["sad"]["d"] == creder.said + + # Stage 3: holder answers the request with an offer that references the + # stored credential and points back to the apply SAID. + offer, offer_sigs, offer_atc = holder_client.ipex().offer( + holder_name, + verifier["prefix"], + matching_credentials[0]["sad"], + applySaid=apply_said, + dt="2026-03-25T00:00:01.000000+00:00", + ) + wait_for_operation( + holder_client, + holder_client.ipex().submitOffer(holder_name, offer, offer_sigs, offer_atc, [verifier["prefix"]]), + ) + + verifier_offer_note = wait_for_notification(verifier_client, "/exn/ipex/offer") + offer_exchange = verifier_client.exchanges().get(verifier_offer_note["a"]["d"]) + offer_said = offer_exchange["exn"]["d"] + + assert offer_exchange["exn"]["p"] == apply_said + assert offer_exchange["exn"]["e"]["acdc"]["a"]["LEI"] == "5493001KJTIIGC8Y1R17" + + # Stage 4: verifier explicitly agrees to the offered credential before the + # holder is allowed to grant it. + agree, agree_sigs, _ = verifier_client.ipex().agree( + verifier_name, + holder["prefix"], + offer_said, + dt="2026-03-25T00:00:02.000000+00:00", + ) + wait_for_operation( + verifier_client, + verifier_client.ipex().submitAgree(verifier_name, agree, agree_sigs, [holder["prefix"]]), + ) + + holder_agree_note = wait_for_notification(holder_client, "/exn/ipex/agree") + agree_exchange = holder_client.exchanges().get(holder_agree_note["a"]["d"]) + agree_said = agree_exchange["exn"]["d"] + + assert agree_exchange["exn"]["p"] == offer_said + + # Stage 5: holder grants the concrete credential artifacts and chains that + # grant back to the agree SAID. + holder_stored = holder_client.credentials().get(creder.said) + grant, grant_sigs, grant_atc = holder_client.ipex().grant( + name=holder_name, + recipient=verifier["prefix"], + acdc=holder_stored["sad"], + iss=holder_stored["iss"], + anc=holder_stored["anc"], + acdcAttachment=holder_stored.get("atc"), + issAttachment=holder_stored.get("issatc"), + ancAttachment=holder_stored.get("ancatc"), + agreeSaid=agree_said, + dt="2026-03-25T00:00:03.000000+00:00", + ) + wait_for_operation( + holder_client, + holder_client.ipex().submitGrant(holder_name, grant, grant_sigs, grant_atc, [verifier["prefix"]]), + ) + + verifier_grant_note = wait_for_notification(verifier_client, "/exn/ipex/grant") + verifier_grant = verifier_client.exchanges().get(verifier_grant_note["a"]["d"]) + assert verifier_grant["exn"]["p"] == agree_said + + # Stage 6: verifier admits the grant, which is the protocol step that + # should result in a stored credential on the verifier side. + admit, admit_sigs, admit_atc = verifier_client.ipex().admit( + name=verifier_name, + recipient=holder["prefix"], + grantSaid=verifier_grant_note["a"]["d"], + dt="2026-03-25T00:00:04.000000+00:00", + ) + wait_for_operation( + verifier_client, + verifier_client.ipex().submitAdmit(verifier_name, admit, admit_sigs, admit_atc, [holder["prefix"]]), + ) + wait_for_notification(holder_client, "/exn/ipex/admit") + + verifier_received = wait_for_credential(verifier_client, creder.said) + verifier_fetched = verifier_client.credentials().get(creder.said) + verifier_exported = verifier_client.credentials().export(creder.said) + verifier_filtered = verifier_client.credentials().list(filter={"-a-i": holder["prefix"]}) + + assert verifier_received["sad"]["d"] == creder.said + assert verifier_received["sad"]["a"]["i"] == holder["prefix"] + assert verifier_received["sad"]["i"] == issuer["prefix"] + assert verifier_fetched["sad"]["d"] == creder.said + assert any(credential["sad"]["d"] == creder.said for credential in verifier_filtered) + assert verifier_exported + + +def test_holder_credential_delete_readback(client_factory): + """Prove local credential deletion removes both JSON and CESR read paths. + + Once the holder deletes the stored credential copy, maintained read + wrappers should stop finding it. + """ + issuer_client = client_factory() + holder_client = client_factory() + issuer_name = alias("issuer") + holder_name = alias("holder") + registry_name = alias("registry") + + issuer = create_identifier(issuer_client, issuer_name, wits=TEST_WITNESS_AIDS) + holder = create_identifier(holder_client, holder_name, wits=TEST_WITNESS_AIDS) + exchange_agent_oobis(issuer_client, issuer_name, holder_client, holder_name) + resolve_schema_oobi(issuer_client, QVI_SCHEMA_SAID) + resolve_schema_oobi(holder_client, QVI_SCHEMA_SAID) + + create_registry(issuer_client, issuer_name, registry_name) + creder, iserder, anc, sigs = issue_credential( + issuer_client, + issuer_name=issuer_name, + registry_name=registry_name, + recipient=holder["prefix"], + data={"LEI": "5493001KJTIIGC8Y1R17"}, + ) + send_credential_grant( + issuer_client, + issuer_name=issuer_name, + recipient=holder["prefix"], + creder=creder, + iserder=iserder, + anc=anc, + sigs=sigs, + ) + + grant_note = wait_for_notification(holder_client, "/exn/ipex/grant") + submit_admit( + holder_client, + holder_name=holder_name, + issuer_prefix=issuer["prefix"], + notification=grant_note, + ) + wait_for_notification(issuer_client, "/exn/ipex/admit") + + fetched = wait_for_credential(holder_client, creder.said) + fetched_cesr = holder_client.credentials().get(creder.said, includeCESR=True) + holder_client.credentials().delete(creder.said) + + with pytest.raises(HTTPError): + holder_client.credentials().get(creder.said) + + with pytest.raises(HTTPError): + holder_client.credentials().get(creder.said, includeCESR=True) + + listed_all_after_delete = holder_client.credentials().list() + listed_after_delete = holder_client.credentials().list(filter={"-a-i": holder["prefix"]}) + issuer_still_has_copy = issuer_client.credentials().list(filter={"-i": issuer["prefix"]}) + + assert fetched["sad"]["d"] == creder.said + assert fetched_cesr + assert listed_all_after_delete == [] + assert all(credential["sad"]["d"] != creder.said for credential in listed_after_delete) + assert any(credential["sad"]["d"] == creder.said for credential in issuer_still_has_copy) + + +def test_chained_credential_issue_with_rules_and_edges(client_factory): + """Prove canonical `issue(...)` handles chained-credential inputs end to end. + + SignifyTS locks down more than the simplest QVI issuance: later credential + families depend on `issue(...)` carrying source edges and rules cleanly + enough that the recipient can read back chain material after IPEX. + + Workflow summary: + - issuer issues a QVI credential to holder + - holder receives it through grant/admit + - holder then acts as issuer for a legal-entity credential + - the legal-entity credential must carry both rules and a source edge back + to the received QVI credential + """ + issuer_client = client_factory() + holder_client = client_factory() + legal_entity_client = client_factory() + issuer_name = alias("issuer") + holder_name = alias("holder") + legal_entity_name = alias("legal-entity") + issuer_registry_name = alias("issuer-registry") + holder_registry_name = alias("holder-registry") + + issuer = create_identifier(issuer_client, issuer_name, wits=TEST_WITNESS_AIDS) + holder = create_identifier(holder_client, holder_name, wits=TEST_WITNESS_AIDS) + legal_entity = create_identifier(legal_entity_client, legal_entity_name, wits=TEST_WITNESS_AIDS) + exchange_agent_oobis(issuer_client, issuer_name, holder_client, holder_name) + exchange_agent_oobis(holder_client, holder_name, legal_entity_client, legal_entity_name) + + resolve_schema_oobi(issuer_client, QVI_SCHEMA_SAID) + resolve_schema_oobi(holder_client, QVI_SCHEMA_SAID) + resolve_schema_oobi(legal_entity_client, QVI_SCHEMA_SAID) + + resolve_schema_oobi( + holder_client, + ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"], + alias="legal-entity-schema", + ) + resolve_schema_oobi( + legal_entity_client, + ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"], + alias="legal-entity-schema", + ) + + # Stage 1: bootstrap the parent QVI credential that later source edges + # should reference. + create_registry(issuer_client, issuer_name, issuer_registry_name) + qvi_creder, qvi_iss, qvi_anc, qvi_sigs = issue_credential( + issuer_client, + issuer_name=issuer_name, + registry_name=issuer_registry_name, + recipient=holder["prefix"], + data={"LEI": "5493001KJTIIGC8Y1R17"}, + ) + send_credential_grant( + issuer_client, + issuer_name=issuer_name, + recipient=holder["prefix"], + creder=qvi_creder, + iserder=qvi_iss, + anc=qvi_anc, + sigs=qvi_sigs, + ) + grant_note = wait_for_notification(holder_client, "/exn/ipex/grant") + submit_admit( + holder_client, + holder_name=holder_name, + issuer_prefix=issuer["prefix"], + notification=grant_note, + ) + wait_for_notification(issuer_client, "/exn/ipex/admit") + qvi_credential = wait_for_credential(holder_client, qvi_creder.said) + + # Stage 2: issuer of the downstream credential creates its own registry and + # builds the explicit rules and source-edge material. + create_registry(holder_client, holder_name, holder_registry_name) + + rules = coring.Saider.saidify( + sad={ + "d": "", + "usageDisclaimer": { + "l": LE_USAGE_DISCLAIMER + }, + "issuanceDisclaimer": { + "l": LE_ISSUANCE_DISCLAIMER + }, + } + )[1] + edges = coring.Saider.saidify( + sad={ + "d": "", + "qvi": { + "n": qvi_credential["sad"]["d"], + "s": qvi_credential["sad"]["s"], + }, + } + )[1] + + # Stage 3: issue the chained legal-entity credential and transport it + # through the same single-sig grant/admit path. + le_issue = holder_client.credentials().issue( + holder_name, + holder_registry_name, + data={"LEI": "5493001KJTIIGC8Y1R17"}, + schema=ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"], + recipient=legal_entity["prefix"], + edges=edges, + rules=rules, + ) + wait_for_operation(holder_client, le_issue.op()) + send_credential_grant( + holder_client, + issuer_name=holder_name, + recipient=legal_entity["prefix"], + creder=le_issue.acdc, + iserder=le_issue.iss, + anc=le_issue.anc, + sigs=le_issue.sigs, + ) + grant_note = wait_for_notification(legal_entity_client, "/exn/ipex/grant") + submit_admit( + legal_entity_client, + holder_name=legal_entity_name, + issuer_prefix=holder["prefix"], + notification=grant_note, + ) + wait_for_notification(holder_client, "/exn/ipex/admit") + legal_entity_credential = wait_for_credential(legal_entity_client, le_issue.acdc.said) + fetched = legal_entity_client.credentials().get(le_issue.acdc.said) + holder_filtered = holder_client.credentials().list( + filter={"-s": ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"]} + ) + + assert le_issue.acdc.sad["s"] == ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"] + assert le_issue.acdc.sad["a"]["i"] == legal_entity["prefix"] + assert legal_entity_credential["sad"]["d"] == le_issue.acdc.said + assert legal_entity_credential["sad"]["s"] == ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"] + assert legal_entity_credential["sad"]["i"] == holder["prefix"] + assert legal_entity_credential["status"]["s"] == "0" + assert fetched["sad"]["d"] == le_issue.acdc.said + assert isinstance(fetched["chains"], list) + assert fetched["chains"] + assert fetched["chains"][0]["sad"]["d"] == qvi_creder.said + assert fetched["atc"] + assert any(credential["sad"]["d"] == le_issue.acdc.said for credential in holder_filtered) + + def test_registry_rename_read_path(client_factory): """Preserve the old registry-rename script as one direct read-path contract. @@ -138,8 +615,8 @@ def test_single_sig_credential_revocation(client_factory): issuer = create_identifier(issuer_client, issuer_name, wits=TEST_WITNESS_AIDS) holder = create_identifier(holder_client, holder_name, wits=TEST_WITNESS_AIDS) exchange_agent_oobis(issuer_client, issuer_name, holder_client, holder_name) - resolve_schema_oobi(issuer_client) - resolve_schema_oobi(holder_client) + resolve_schema_oobi(issuer_client, QVI_SCHEMA_SAID) + resolve_schema_oobi(holder_client, QVI_SCHEMA_SAID) _, registry = create_registry(issuer_client, issuer_name, registry_name) creder, _, _, _ = issue_credential( diff --git a/tests/integration/test_multisig_credentials.py b/tests/integration/test_multisig_credentials.py index e792fb4..427ffa4 100644 --- a/tests/integration/test_multisig_credentials.py +++ b/tests/integration/test_multisig_credentials.py @@ -1,4 +1,10 @@ -"""Live multisig credential workflows that replace the legacy script demos.""" +"""Live multisig credential and IPEX workflow coverage. + +These tests are intentionally more narrative than most unit tests because the +main failure modes are protocol-shape mistakes: wrong OOBIs, wrong proposal +replay payloads, wrong grant/admit ordering, or asserting on notifications +instead of the stored exchanges and credentials they point to. +""" from __future__ import annotations @@ -6,7 +12,7 @@ from keri.core import coring from keri.help import helping -from .constants import SCHEMA_SAID, TEST_WITNESS_AIDS +from .constants import ADDITIONAL_SCHEMA_OOBI_SAIDS, QVI_SCHEMA_SAID, TEST_WITNESS_AIDS from .helpers import ( alias, create_identifier, @@ -20,16 +26,51 @@ revoke_multisig_credential, resolve_oobi, resolve_schema_oobi, + send_credential_grant, + send_multisig_credential_grant, + submit_admit, + submit_multisig_admit, + wait_for_credential, + wait_for_exchange, + wait_for_filtered_credential, wait_for_issued_credential, wait_for_multisig_credential_state_convergence, + wait_for_multisig_received_credential, wait_for_multisig_registry_convergence, wait_for_multisig_request, + wait_for_notification, + wait_for_notification_any, wait_for_operation, ) pytestmark = pytest.mark.integration +LE_USAGE_DISCLAIMER = ( + "Usage of a valid, unexpired, and non-revoked vLEI Credential, as defined " + "in the associated Ecosystem Governance Framework, does not assert that " + "the Legal Entity is trustworthy, honest, reputable in its business " + "dealings, safe to do business with, or compliant with any laws or that " + "an implied or expressly intended purpose will be fulfilled." +) + +LE_ISSUANCE_DISCLAIMER = ( + "All information in a valid, unexpired, and non-revoked vLEI Credential, " + "as defined in the associated Ecosystem Governance Framework, is accurate " + "as of the date the validation process was complete. The vLEI Credential " + "has been issued to the legal entity or person named in the vLEI " + "Credential as the subject; and the qualified vLEI Issuer exercised " + "reasonable care to perform the validation process set forth in the vLEI " + "Ecosystem Governance Framework." +) + +LE_DATA = {"LEI": "875500ELOZEL05BVXV37"} +OOR_DATA = { + "LEI": LE_DATA["LEI"], + "personLegalName": "John Doe", + "officialRole": "HR Manager", +} + def _normalized_registry_state(registry: dict) -> dict: """Strip timestamp-only churn before comparing registry state across members.""" @@ -38,6 +79,202 @@ def _normalized_registry_state(registry: dict) -> dict: return state +def _assert_credential_record( + credential: dict, + *, + said: str, + issuer_prefix: str, + subject_prefix: str, + expected_et: str, + expected_sn: str, + schema_said: str = QVI_SCHEMA_SAID, +) -> None: + """Assert the stable credential fields that should match across members.""" + assert credential["sad"]["d"] == said + assert credential["sad"]["i"] == issuer_prefix + assert credential["sad"]["a"]["i"] == subject_prefix + assert credential["sad"]["s"] == schema_said + assert credential["status"]["et"] == expected_et + assert credential["status"]["s"] == expected_sn + + +def _assert_issuer_query_surface( + client, + *, + issuer_prefix: str, + subject_prefix: str, + registry_said: str, + said: str, + expected_et: str, + expected_sn: str, + schema_said: str = QVI_SCHEMA_SAID, +) -> None: + """Assert the maintained issuer-side query/read surface for one credential.""" + all_credentials = client.credentials().list() + issuer_filtered = client.credentials().list(filter={"-i": issuer_prefix}) + schema_filtered = client.credentials().list(filter={"-s": schema_said}) + subject_filtered = client.credentials().list(filter={"-a-i": subject_prefix}) + combined_filtered = client.credentials().list( + filter={"-i": issuer_prefix, "-s": schema_said, "-a-i": subject_prefix} + ) + fetched = client.credentials().get(said) + fetched_cesr = client.credentials().get(said, includeCESR=True) + exported = client.credentials().export(said) + state = client.credentials().state(registry_said, said) + + assert len(all_credentials) == 1 + assert len(issuer_filtered) == 1 + assert len(schema_filtered) == 1 + assert len(subject_filtered) == 1 + assert len(combined_filtered) == 1 + _assert_credential_record( + all_credentials[0], + said=said, + issuer_prefix=issuer_prefix, + subject_prefix=subject_prefix, + expected_et=expected_et, + expected_sn=expected_sn, + schema_said=schema_said, + ) + _assert_credential_record( + issuer_filtered[0], + said=said, + issuer_prefix=issuer_prefix, + subject_prefix=subject_prefix, + expected_et=expected_et, + expected_sn=expected_sn, + schema_said=schema_said, + ) + _assert_credential_record( + schema_filtered[0], + said=said, + issuer_prefix=issuer_prefix, + subject_prefix=subject_prefix, + expected_et=expected_et, + expected_sn=expected_sn, + schema_said=schema_said, + ) + _assert_credential_record( + subject_filtered[0], + said=said, + issuer_prefix=issuer_prefix, + subject_prefix=subject_prefix, + expected_et=expected_et, + expected_sn=expected_sn, + schema_said=schema_said, + ) + _assert_credential_record( + combined_filtered[0], + said=said, + issuer_prefix=issuer_prefix, + subject_prefix=subject_prefix, + expected_et=expected_et, + expected_sn=expected_sn, + schema_said=schema_said, + ) + _assert_credential_record( + fetched, + said=said, + issuer_prefix=issuer_prefix, + subject_prefix=subject_prefix, + expected_et=expected_et, + expected_sn=expected_sn, + schema_said=schema_said, + ) + assert fetched_cesr + assert exported == fetched_cesr + assert state["et"] == expected_et + assert state["s"] == expected_sn + + +def _assert_holder_read_surface( + client, + *, + issuer_prefix: str, + holder_prefix: str, + said: str, + schema_said: str = QVI_SCHEMA_SAID, + assert_subject_filter: bool = True, +) -> None: + """Assert the maintained holder-side read surface for one received credential.""" + all_credentials = client.credentials().list() + fetched = client.credentials().get(said) + fetched_cesr = client.credentials().get(said, includeCESR=True) + exported = client.credentials().export(said) + matching_all = [ + credential for credential in all_credentials if credential["sad"]["d"] == said + ] + matching_holder = ( + wait_for_filtered_credential(client, said, filter={"-a-i": holder_prefix}) + if assert_subject_filter + else None + ) + + assert len(matching_all) == 1 + _assert_credential_record( + matching_all[0], + said=said, + issuer_prefix=issuer_prefix, + subject_prefix=holder_prefix, + expected_et="iss", + expected_sn="0", + schema_said=schema_said, + ) + if assert_subject_filter: + _assert_credential_record( + matching_holder, + said=said, + issuer_prefix=issuer_prefix, + subject_prefix=holder_prefix, + expected_et="iss", + expected_sn="0", + schema_said=schema_said, + ) + _assert_credential_record( + fetched, + said=said, + issuer_prefix=issuer_prefix, + subject_prefix=holder_prefix, + expected_et="iss", + expected_sn="0", + schema_said=schema_said, + ) + assert fetched_cesr + assert exported == fetched_cesr + + +def _resolve_schema_set(client, *schema_saids: str) -> None: + """Resolve a small explicit set of schema OOBIs for one participant.""" + for schema_said in schema_saids: + resolve_schema_oobi(client, schema_said) + + +def _le_rules() -> dict: + """Build the shared rules payload used by LE/OOR-family credentials.""" + return coring.Saider.saidify( + sad={ + "d": "", + "usageDisclaimer": {"l": LE_USAGE_DISCLAIMER}, + "issuanceDisclaimer": {"l": LE_ISSUANCE_DISCLAIMER}, + } + )[1] + + +def _source_edges(label: str, credential, *, operator: str | None = None) -> dict: + """Build a saidified source-edge payload for one parent credential.""" + sad = credential["sad"] if isinstance(credential, dict) else credential.sad + edge = {"n": sad["d"], "s": sad["s"]} + if operator is not None: + edge["o"] = operator + return coring.Saider.saidify(sad={"d": "", label: edge})[1] + + +def _assert_filtered_contains(client, *, schema_said: str, subject_prefix: str, said: str) -> None: + """Assert one issuer-side filtered list contains the expected credential SAID.""" + filtered = client.credentials().list(filter={"-s": schema_said, "-a-i": subject_prefix}) + assert any(credential["sad"]["d"] == said for credential in filtered) + + def test_single_sig_issuer_to_multisig_holder_credential_issue(client_factory): """Prove a single issuer can issue to one multisig holder group prefix. @@ -65,9 +302,9 @@ def test_single_sig_issuer_to_multisig_holder_credential_issue(client_factory): exchange_agent_oobis(holder_client_a, holder_member_a_name, holder_client_b, holder_member_b_name) exchange_agent_oobis(issuer_client, issuer_name, holder_client_a, holder_member_a_name) exchange_agent_oobis(issuer_client, issuer_name, holder_client_b, holder_member_b_name) - resolve_schema_oobi(issuer_client) - resolve_schema_oobi(holder_client_a) - resolve_schema_oobi(holder_client_b) + resolve_schema_oobi(issuer_client, QVI_SCHEMA_SAID) + resolve_schema_oobi(holder_client_a, QVI_SCHEMA_SAID) + resolve_schema_oobi(holder_client_b, QVI_SCHEMA_SAID) holder_group_a, holder_group_b = create_multisig_group( holder_client_a, @@ -94,13 +331,13 @@ def test_single_sig_issuer_to_multisig_holder_credential_issue(client_factory): recipient=holder_group_a["prefix"], data={"LEI": "5493001KJTIIGC8Y1R17"}, ) - issued = issuer_client.credentials().list(filtr={"-i": issuer["prefix"]}) + issued = issuer_client.credentials().list(filter={"-i": issuer["prefix"]}) assert registry["name"] == registry_name assert holder_group_a["prefix"] == holder_group_b["prefix"] assert creder.sad["d"] == iserder.ked["i"] assert creder.sad["a"]["i"] == holder_group_a["prefix"] - assert creder.sad["s"] == SCHEMA_SAID + assert creder.sad["s"] == QVI_SCHEMA_SAID assert any(credential["sad"]["d"] == creder.said for credential in issued) @@ -143,10 +380,10 @@ def test_multisig_issuer_to_multisig_holder_credential_issue(client_factory): exchange_agent_oobis(issuer_client_a, issuer_member_a_name, issuer_client_b, issuer_member_b_name) exchange_agent_oobis(holder_client_a, holder_member_a_name, holder_client_b, holder_member_b_name) - resolve_schema_oobi(issuer_client_a) - resolve_schema_oobi(issuer_client_b) - resolve_schema_oobi(holder_client_a) - resolve_schema_oobi(holder_client_b) + resolve_schema_oobi(issuer_client_a, QVI_SCHEMA_SAID) + resolve_schema_oobi(issuer_client_b, QVI_SCHEMA_SAID) + resolve_schema_oobi(holder_client_a, QVI_SCHEMA_SAID) + resolve_schema_oobi(holder_client_b, QVI_SCHEMA_SAID) issuer_group_a, issuer_group_b = create_multisig_group( issuer_client_a, @@ -273,6 +510,24 @@ def test_multisig_issuer_to_multisig_holder_credential_issue(client_factory): assert creder_a.sad["a"]["i"] == holder_group_a["prefix"] assert issued_a["sad"]["d"] == creder_a.said assert issued_b["sad"]["d"] == creder_a.said + _assert_issuer_query_surface( + issuer_client_a, + issuer_prefix=issuer_group_a["prefix"], + subject_prefix=holder_group_a["prefix"], + registry_said=registry_a["regk"], + said=creder_a.said, + expected_et="iss", + expected_sn="0", + ) + _assert_issuer_query_surface( + issuer_client_b, + issuer_prefix=issuer_group_b["prefix"], + subject_prefix=holder_group_b["prefix"], + registry_said=registry_b["regk"], + said=creder_a.said, + expected_et="iss", + expected_sn="0", + ) def test_multisig_issuer_credential_revocation(client_factory): @@ -306,9 +561,9 @@ def test_multisig_issuer_credential_revocation(client_factory): exchange_agent_oobis(issuer_client_a, issuer_member_a_name, issuer_client_b, issuer_member_b_name) exchange_agent_oobis(issuer_client_a, issuer_member_a_name, holder_client, holder_name) - resolve_schema_oobi(issuer_client_a) - resolve_schema_oobi(issuer_client_b) - resolve_schema_oobi(holder_client) + resolve_schema_oobi(issuer_client_a, QVI_SCHEMA_SAID) + resolve_schema_oobi(issuer_client_b, QVI_SCHEMA_SAID) + resolve_schema_oobi(holder_client, QVI_SCHEMA_SAID) issuer_group_a, issuer_group_b = create_multisig_group( issuer_client_a, @@ -432,5 +687,1129 @@ def test_multisig_issuer_credential_revocation(client_factory): assert issued_a["sad"]["d"] == creder_a.said assert issued_b["sad"]["d"] == creder_a.said assert registry_a["regk"] == registry_b["regk"] + fetched_a = issuer_client_a.credentials().get(creder_a.said) + fetched_b = issuer_client_b.credentials().get(creder_a.said) + issuer_filtered_a = issuer_client_a.credentials().list(filter={"-i": issuer_group_a["prefix"]}) + issuer_filtered_b = issuer_client_b.credentials().list(filter={"-i": issuer_group_b["prefix"]}) + fetched_cesr_a = issuer_client_a.credentials().get(creder_a.said, includeCESR=True) + fetched_cesr_b = issuer_client_b.credentials().get(creder_a.said, includeCESR=True) + exported_a = issuer_client_a.credentials().export(creder_a.said) + exported_b = issuer_client_b.credentials().export(creder_a.said) assert revoke_state_a["et"] == "rev" assert revoke_state_b["et"] == "rev" + _assert_credential_record( + fetched_a, + said=creder_a.said, + issuer_prefix=issuer_group_a["prefix"], + subject_prefix=holder["prefix"], + expected_et="rev", + expected_sn="1", + ) + _assert_credential_record( + fetched_b, + said=creder_a.said, + issuer_prefix=issuer_group_b["prefix"], + subject_prefix=holder["prefix"], + expected_et="rev", + expected_sn="1", + ) + assert len(issuer_filtered_a) == 1 + assert len(issuer_filtered_b) == 1 + _assert_credential_record( + issuer_filtered_a[0], + said=creder_a.said, + issuer_prefix=issuer_group_a["prefix"], + subject_prefix=holder["prefix"], + expected_et="rev", + expected_sn="1", + ) + _assert_credential_record( + issuer_filtered_b[0], + said=creder_a.said, + issuer_prefix=issuer_group_b["prefix"], + subject_prefix=holder["prefix"], + expected_et="rev", + expected_sn="1", + ) + assert fetched_cesr_a + assert fetched_cesr_b + assert exported_a == fetched_cesr_a + assert exported_b == fetched_cesr_b + + +def test_multisig_issuer_to_multisig_holder_credential_presentation(client_factory): + """Prove multisig grant/admit stores the credential on both holder members. + + This is the core holder-side parity test for multisig IPEX. The important + contract is: + - both issuer members send the same grant wave + - both grant operations finish before either holder admits + - both holder members converge on one admit wave + - only then should stored-credential reads succeed on both holder members + """ + issuer_client_a = client_factory() + issuer_client_b = client_factory() + holder_client_a = client_factory() + holder_client_b = client_factory() + + issuer_member_a_name = alias("issuer-a") + issuer_member_b_name = alias("issuer-b") + holder_member_a_name = alias("holder-a") + holder_member_b_name = alias("holder-b") + issuer_group_name = alias("issuer-group") + holder_group_name = alias("holder-group") + registry_name = alias("registry") + + issuer_member_a = create_identifier(issuer_client_a, issuer_member_a_name, wits=TEST_WITNESS_AIDS) + issuer_member_b = create_identifier(issuer_client_b, issuer_member_b_name, wits=TEST_WITNESS_AIDS) + holder_member_a = create_identifier(holder_client_a, holder_member_a_name, wits=TEST_WITNESS_AIDS) + holder_member_b = create_identifier(holder_client_b, holder_member_b_name, wits=TEST_WITNESS_AIDS) + + exchange_agent_oobis(issuer_client_a, issuer_member_a_name, issuer_client_b, issuer_member_b_name) + exchange_agent_oobis(holder_client_a, holder_member_a_name, holder_client_b, holder_member_b_name) + _resolve_schema_set(issuer_client_a, QVI_SCHEMA_SAID) + _resolve_schema_set(issuer_client_b, QVI_SCHEMA_SAID) + _resolve_schema_set(holder_client_a, QVI_SCHEMA_SAID) + _resolve_schema_set(holder_client_b, QVI_SCHEMA_SAID) + + # Stage 1: build issuer and holder multisig groups and exchange the base + # multisig OOBIs, not member-specific `/agent/...` OOBIs. + issuer_group_a, issuer_group_b = create_multisig_group( + issuer_client_a, + issuer_member_a_name, + issuer_client_b, + issuer_member_b_name, + issuer_group_name, + wits=TEST_WITNESS_AIDS, + ) + holder_group_a, holder_group_b = create_multisig_group( + holder_client_a, + holder_member_a_name, + holder_client_b, + holder_member_b_name, + holder_group_name, + wits=TEST_WITNESS_AIDS, + ) + issuer_group_oobi = expose_multisig_agent_oobi( + issuer_client_a, + issuer_member_a_name, + issuer_client_b, + issuer_member_b_name, + issuer_group_name, + ) + holder_group_oobi = expose_multisig_agent_oobi( + holder_client_a, + holder_member_a_name, + holder_client_b, + holder_member_b_name, + holder_group_name, + ) + resolve_oobi(issuer_client_a, holder_group_oobi, alias=holder_group_name) + resolve_oobi(issuer_client_b, holder_group_oobi, alias=holder_group_name) + resolve_oobi(holder_client_a, issuer_group_oobi, alias=issuer_group_name) + resolve_oobi(holder_client_b, issuer_group_oobi, alias=issuer_group_name) + + # Stage 2: converge the issuer registry on both issuer members before + # attempting any credential issuance. + registry_nonce = coring.randomNonce() + registry_operation_a, _ = create_multisig_registry( + issuer_client_a, + local_member_name=issuer_member_a_name, + group_name=issuer_group_name, + other_member_prefixes=[issuer_member_b["prefix"]], + registry_name=registry_name, + nonce=registry_nonce, + is_initiator=True, + ) + _, registry_request_b = wait_for_multisig_request(issuer_client_b, "/multisig/vcp") + registry_operation_b, _ = create_multisig_registry( + issuer_client_b, + local_member_name=issuer_member_b_name, + group_name=issuer_group_name, + other_member_prefixes=[issuer_member_a["prefix"]], + registry_name=registry_name, + nonce=registry_nonce, + request=registry_request_b, + ) + wait_for_operation(issuer_client_a, registry_operation_a) + wait_for_operation(issuer_client_b, registry_operation_b) + registry_a, registry_b = wait_for_multisig_registry_convergence( + issuer_client_a, + issuer_client_b, + group_name=issuer_group_name, + registry_name=registry_name, + ) + + # Stage 3: issue the credential through `/multisig/iss` using one shared + # timestamp so both members sign the same issuance payload. + timestamp = helping.nowIso8601() + creder_a, iserder_a, anc_a, sigs_a, credential_operation_a, _ = issue_multisig_credential( + issuer_client_a, + local_member_name=issuer_member_a_name, + group_name=issuer_group_name, + other_member_prefixes=[issuer_member_b["prefix"]], + registry_name=registry_name, + recipient=holder_group_a["prefix"], + data={"LEI": "5493001KJTIIGC8Y1R17"}, + timestamp=timestamp, + is_initiator=True, + ) + _, issuance_request_b = wait_for_multisig_request(issuer_client_b, "/multisig/iss") + creder_b, iserder_b, anc_b, sigs_b, credential_operation_b, _ = issue_multisig_credential( + issuer_client_b, + local_member_name=issuer_member_b_name, + group_name=issuer_group_name, + other_member_prefixes=[issuer_member_a["prefix"]], + registry_name=registry_name, + recipient=holder_group_a["prefix"], + data={"LEI": "5493001KJTIIGC8Y1R17"}, + timestamp=timestamp, + request=issuance_request_b, + ) + wait_for_operation(issuer_client_a, credential_operation_a) + wait_for_operation(issuer_client_b, credential_operation_b) + wait_for_issued_credential(issuer_client_a, issuer_group_a["prefix"], creder_a.said) + wait_for_issued_credential(issuer_client_b, issuer_group_b["prefix"], creder_a.said) + + # Stage 4: complete the full grant wave before either holder starts the + # admit wave. This mirrors the working TS/KLI contract. + grant_timestamp = helping.nowIso8601() + grant_operation_a = send_multisig_credential_grant( + issuer_client_a, + local_member_name=issuer_member_a_name, + group_name=issuer_group_name, + other_member_prefixes=[issuer_member_b["prefix"]], + recipient=holder_group_a["prefix"], + creder=creder_a, + iserder=iserder_a, + anc=anc_a, + sigs=sigs_a, + timestamp=grant_timestamp, + is_initiator=True, + ) + grant_operation_b = send_multisig_credential_grant( + issuer_client_b, + local_member_name=issuer_member_b_name, + group_name=issuer_group_name, + other_member_prefixes=[issuer_member_a["prefix"]], + recipient=holder_group_a["prefix"], + creder=creder_b, + iserder=iserder_b, + anc=anc_b, + sigs=sigs_b, + timestamp=grant_timestamp, + ) + wait_for_operation(issuer_client_a, grant_operation_a) + wait_for_operation(issuer_client_b, grant_operation_b) + + # Stage 5: admit from both holder members using the same stored grant SAID + # and one shared admit timestamp. + grant_client_index, grant_note = wait_for_notification_any( + [holder_client_a, holder_client_b], + "/exn/ipex/grant", + ) + grant_said = grant_note["a"]["d"] + admit_timestamp = helping.nowIso8601() + if grant_client_index == 0: + first_holder_client, first_holder_name, first_peer_prefixes = ( + holder_client_a, + holder_member_a_name, + [holder_member_b["prefix"]], + ) + second_holder_client, second_holder_name, second_peer_prefixes = ( + holder_client_b, + holder_member_b_name, + [holder_member_a["prefix"]], + ) + else: + first_holder_client, first_holder_name, first_peer_prefixes = ( + holder_client_b, + holder_member_b_name, + [holder_member_a["prefix"]], + ) + second_holder_client, second_holder_name, second_peer_prefixes = ( + holder_client_a, + holder_member_a_name, + [holder_member_b["prefix"]], + ) + first_admit_operation = submit_multisig_admit( + first_holder_client, + local_member_name=first_holder_name, + group_name=holder_group_name, + other_member_prefixes=first_peer_prefixes, + issuer_prefix=issuer_group_a["prefix"], + grant_said=grant_said, + timestamp=admit_timestamp, + ) + wait_for_exchange(second_holder_client, grant_said, expected_route="/ipex/grant") + second_admit_operation = submit_multisig_admit( + second_holder_client, + local_member_name=second_holder_name, + group_name=holder_group_name, + other_member_prefixes=second_peer_prefixes, + issuer_prefix=issuer_group_a["prefix"], + grant_said=grant_said, + timestamp=admit_timestamp, + ) + wait_for_operation(first_holder_client, first_admit_operation) + wait_for_operation(second_holder_client, second_admit_operation) + + # Stage 6: notifications tell us what to inspect, but stored credentials + # are the authoritative success signal. + issuer_admit_note_a = wait_for_notification(issuer_client_a, "/exn/ipex/admit") + issuer_admit_note_b = wait_for_notification(issuer_client_b, "/exn/ipex/admit") + assert issuer_admit_note_a["a"]["d"] == issuer_admit_note_b["a"]["d"] + + holder_received_a, holder_received_b = wait_for_multisig_received_credential( + holder_client_a, + holder_client_b, + creder_a.said, + ) + + assert registry_a["regk"] == registry_b["regk"] + assert creder_a.said == creder_b.said + assert holder_received_a["sad"]["d"] == creder_a.said + assert holder_received_b["sad"]["d"] == creder_a.said + _assert_holder_read_surface( + holder_client_a, + issuer_prefix=issuer_group_a["prefix"], + holder_prefix=holder_group_a["prefix"], + said=creder_a.said, + ) + _assert_holder_read_surface( + holder_client_b, + issuer_prefix=issuer_group_b["prefix"], + holder_prefix=holder_group_b["prefix"], + said=creder_a.said, + ) + _assert_issuer_query_surface( + issuer_client_a, + issuer_prefix=issuer_group_a["prefix"], + subject_prefix=holder_group_a["prefix"], + registry_said=registry_a["regk"], + said=creder_a.said, + expected_et="iss", + expected_sn="0", + ) + _assert_issuer_query_surface( + issuer_client_b, + issuer_prefix=issuer_group_b["prefix"], + subject_prefix=holder_group_b["prefix"], + registry_said=registry_b["regk"], + said=creder_a.said, + expected_et="iss", + expected_sn="0", + ) + + +def test_multisig_chained_qvi_le_oor_auth_oor_presentation(client_factory): + """Prove chained multisig grant/admit flows preserve readable credential chains. + + This is the most complex live credential workflow in the suite, so the + important shape is worth stating explicitly: + - GEDA-equivalent group grants QVI to the QVI group + - QVI group grants LE to the LE group + - LE group grants OOR Auth back to the QVI group + - QVI group grants OOR to a single-sig role holder + + Every hop follows the same discipline: shared timestamps per multisig wave, + finish all grants before any admits, then assert on stored credential + visibility rather than treating notifications as the final truth. + """ + geda_client_a = client_factory() + geda_client_b = client_factory() + qvi_client_a = client_factory() + qvi_client_b = client_factory() + le_client_a = client_factory() + le_client_b = client_factory() + role_client = client_factory() + + geda_member_a_name = alias("geda-a") + geda_member_b_name = alias("geda-b") + qvi_member_a_name = alias("qvi-a") + qvi_member_b_name = alias("qvi-b") + le_member_a_name = alias("le-a") + le_member_b_name = alias("le-b") + role_holder_name = alias("oor-holder") + geda_group_name = alias("geda-group") + qvi_group_name = alias("qvi-group") + le_group_name = alias("le-group") + geda_registry_name = alias("geda-registry") + qvi_registry_name = alias("qvi-registry") + le_registry_name = alias("le-registry") + + geda_member_a = create_identifier(geda_client_a, geda_member_a_name, wits=TEST_WITNESS_AIDS) + geda_member_b = create_identifier(geda_client_b, geda_member_b_name, wits=TEST_WITNESS_AIDS) + qvi_member_a = create_identifier(qvi_client_a, qvi_member_a_name, wits=TEST_WITNESS_AIDS) + qvi_member_b = create_identifier(qvi_client_b, qvi_member_b_name, wits=TEST_WITNESS_AIDS) + le_member_a = create_identifier(le_client_a, le_member_a_name, wits=TEST_WITNESS_AIDS) + le_member_b = create_identifier(le_client_b, le_member_b_name, wits=TEST_WITNESS_AIDS) + role_holder = create_identifier(role_client, role_holder_name, wits=TEST_WITNESS_AIDS) + + exchange_agent_oobis(geda_client_a, geda_member_a_name, geda_client_b, geda_member_b_name) + exchange_agent_oobis(qvi_client_a, qvi_member_a_name, qvi_client_b, qvi_member_b_name) + exchange_agent_oobis(le_client_a, le_member_a_name, le_client_b, le_member_b_name) + exchange_agent_oobis(qvi_client_a, qvi_member_a_name, role_client, role_holder_name) + exchange_agent_oobis(qvi_client_b, qvi_member_b_name, role_client, role_holder_name) + _resolve_schema_set(geda_client_a, QVI_SCHEMA_SAID) + _resolve_schema_set(geda_client_b, QVI_SCHEMA_SAID) + _resolve_schema_set( + qvi_client_a, + QVI_SCHEMA_SAID, + ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"], + ADDITIONAL_SCHEMA_OOBI_SAIDS["oor-auth"], + ADDITIONAL_SCHEMA_OOBI_SAIDS["oor"], + ) + _resolve_schema_set( + qvi_client_b, + QVI_SCHEMA_SAID, + ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"], + ADDITIONAL_SCHEMA_OOBI_SAIDS["oor-auth"], + ADDITIONAL_SCHEMA_OOBI_SAIDS["oor"], + ) + _resolve_schema_set( + le_client_a, + QVI_SCHEMA_SAID, + ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"], + ADDITIONAL_SCHEMA_OOBI_SAIDS["oor-auth"], + ) + _resolve_schema_set( + le_client_b, + QVI_SCHEMA_SAID, + ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"], + ADDITIONAL_SCHEMA_OOBI_SAIDS["oor-auth"], + ) + _resolve_schema_set( + role_client, + QVI_SCHEMA_SAID, + ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"], + ADDITIONAL_SCHEMA_OOBI_SAIDS["oor-auth"], + ADDITIONAL_SCHEMA_OOBI_SAIDS["oor"], + ) + + # Stage 1: build the three multisig organizations and expose only the base + # multisig OOBIs that peers should resolve. + geda_group_a, geda_group_b = create_multisig_group( + geda_client_a, + geda_member_a_name, + geda_client_b, + geda_member_b_name, + geda_group_name, + wits=TEST_WITNESS_AIDS, + ) + qvi_group_a, qvi_group_b = create_multisig_group( + qvi_client_a, + qvi_member_a_name, + qvi_client_b, + qvi_member_b_name, + qvi_group_name, + wits=TEST_WITNESS_AIDS, + ) + le_group_a, le_group_b = create_multisig_group( + le_client_a, + le_member_a_name, + le_client_b, + le_member_b_name, + le_group_name, + wits=TEST_WITNESS_AIDS, + ) + geda_group_oobi = expose_multisig_agent_oobi( + geda_client_a, + geda_member_a_name, + geda_client_b, + geda_member_b_name, + geda_group_name, + ) + qvi_group_oobi = expose_multisig_agent_oobi( + qvi_client_a, + qvi_member_a_name, + qvi_client_b, + qvi_member_b_name, + qvi_group_name, + ) + le_group_oobi = expose_multisig_agent_oobi( + le_client_a, + le_member_a_name, + le_client_b, + le_member_b_name, + le_group_name, + ) + resolve_oobi(geda_client_a, qvi_group_oobi, alias=qvi_group_name) + resolve_oobi(geda_client_b, qvi_group_oobi, alias=qvi_group_name) + resolve_oobi(qvi_client_a, geda_group_oobi, alias=geda_group_name) + resolve_oobi(qvi_client_b, geda_group_oobi, alias=geda_group_name) + resolve_oobi(qvi_client_a, le_group_oobi, alias=le_group_name) + resolve_oobi(qvi_client_b, le_group_oobi, alias=le_group_name) + resolve_oobi(le_client_a, qvi_group_oobi, alias=qvi_group_name) + resolve_oobi(le_client_b, qvi_group_oobi, alias=qvi_group_name) + resolve_oobi(role_client, qvi_group_oobi, alias=qvi_group_name) + + # Stage 2: GEDA bootstraps the QVI group with a real QVI credential. + geda_registry_nonce = coring.randomNonce() + geda_registry_operation_a, _ = create_multisig_registry( + geda_client_a, + local_member_name=geda_member_a_name, + group_name=geda_group_name, + other_member_prefixes=[geda_member_b["prefix"]], + registry_name=geda_registry_name, + nonce=geda_registry_nonce, + is_initiator=True, + ) + _, geda_registry_request_b = wait_for_multisig_request(geda_client_b, "/multisig/vcp") + geda_registry_operation_b, _ = create_multisig_registry( + geda_client_b, + local_member_name=geda_member_b_name, + group_name=geda_group_name, + other_member_prefixes=[geda_member_a["prefix"]], + registry_name=geda_registry_name, + nonce=geda_registry_nonce, + request=geda_registry_request_b, + ) + wait_for_operation(geda_client_a, geda_registry_operation_a) + wait_for_operation(geda_client_b, geda_registry_operation_b) + geda_registry_a, geda_registry_b = wait_for_multisig_registry_convergence( + geda_client_a, + geda_client_b, + group_name=geda_group_name, + registry_name=geda_registry_name, + ) + + qvi_issue_timestamp = helping.nowIso8601() + qvi_creder_a, qvi_iss_a, qvi_anc_a, qvi_sigs_a, qvi_issue_operation_a, _ = issue_multisig_credential( + geda_client_a, + local_member_name=geda_member_a_name, + group_name=geda_group_name, + other_member_prefixes=[geda_member_b["prefix"]], + registry_name=geda_registry_name, + recipient=qvi_group_a["prefix"], + data={"LEI": "254900OPPU84GM83MG36"}, + timestamp=qvi_issue_timestamp, + is_initiator=True, + ) + _, qvi_issue_request_b = wait_for_multisig_request(geda_client_b, "/multisig/iss") + qvi_creder_b, qvi_iss_b, qvi_anc_b, qvi_sigs_b, qvi_issue_operation_b, _ = issue_multisig_credential( + geda_client_b, + local_member_name=geda_member_b_name, + group_name=geda_group_name, + other_member_prefixes=[geda_member_a["prefix"]], + registry_name=geda_registry_name, + recipient=qvi_group_a["prefix"], + data={"LEI": "254900OPPU84GM83MG36"}, + timestamp=qvi_issue_timestamp, + request=qvi_issue_request_b, + ) + wait_for_operation(geda_client_a, qvi_issue_operation_a) + wait_for_operation(geda_client_b, qvi_issue_operation_b) + wait_for_issued_credential(geda_client_a, geda_group_a["prefix"], qvi_creder_a.said) + wait_for_issued_credential(geda_client_b, geda_group_b["prefix"], qvi_creder_a.said) + + # Finish the grant wave first, then let the QVI members admit the same + # grant SAID. + qvi_grant_timestamp = helping.nowIso8601() + qvi_grant_operation_a = send_multisig_credential_grant( + geda_client_a, + local_member_name=geda_member_a_name, + group_name=geda_group_name, + other_member_prefixes=[geda_member_b["prefix"]], + recipient=qvi_group_a["prefix"], + creder=qvi_creder_a, + iserder=qvi_iss_a, + anc=qvi_anc_a, + sigs=qvi_sigs_a, + timestamp=qvi_grant_timestamp, + is_initiator=True, + ) + qvi_grant_operation_b = send_multisig_credential_grant( + geda_client_b, + local_member_name=geda_member_b_name, + group_name=geda_group_name, + other_member_prefixes=[geda_member_a["prefix"]], + recipient=qvi_group_a["prefix"], + creder=qvi_creder_b, + iserder=qvi_iss_b, + anc=qvi_anc_b, + sigs=qvi_sigs_b, + timestamp=qvi_grant_timestamp, + ) + wait_for_operation(geda_client_a, qvi_grant_operation_a) + wait_for_operation(geda_client_b, qvi_grant_operation_b) + + qvi_grant_client_index, qvi_grant_note = wait_for_notification_any( + [qvi_client_a, qvi_client_b], + "/exn/ipex/grant", + ) + qvi_grant_said = qvi_grant_note["a"]["d"] + qvi_admit_timestamp = helping.nowIso8601() + if qvi_grant_client_index == 0: + first_qvi_client, first_qvi_member_name, first_qvi_peer_prefixes = ( + qvi_client_a, + qvi_member_a_name, + [qvi_member_b["prefix"]], + ) + second_qvi_client, second_qvi_member_name, second_qvi_peer_prefixes = ( + qvi_client_b, + qvi_member_b_name, + [qvi_member_a["prefix"]], + ) + else: + first_qvi_client, first_qvi_member_name, first_qvi_peer_prefixes = ( + qvi_client_b, + qvi_member_b_name, + [qvi_member_a["prefix"]], + ) + second_qvi_client, second_qvi_member_name, second_qvi_peer_prefixes = ( + qvi_client_a, + qvi_member_a_name, + [qvi_member_b["prefix"]], + ) + first_qvi_admit_operation = submit_multisig_admit( + first_qvi_client, + local_member_name=first_qvi_member_name, + group_name=qvi_group_name, + other_member_prefixes=first_qvi_peer_prefixes, + issuer_prefix=geda_group_a["prefix"], + grant_said=qvi_grant_said, + timestamp=qvi_admit_timestamp, + ) + wait_for_exchange(second_qvi_client, qvi_grant_said, expected_route="/ipex/grant") + second_qvi_admit_operation = submit_multisig_admit( + second_qvi_client, + local_member_name=second_qvi_member_name, + group_name=qvi_group_name, + other_member_prefixes=second_qvi_peer_prefixes, + issuer_prefix=geda_group_a["prefix"], + grant_said=qvi_grant_said, + timestamp=qvi_admit_timestamp, + ) + wait_for_operation(first_qvi_client, first_qvi_admit_operation) + wait_for_operation(second_qvi_client, second_qvi_admit_operation) + + geda_admit_note_a = wait_for_notification(geda_client_a, "/exn/ipex/admit") + geda_admit_note_b = wait_for_notification(geda_client_b, "/exn/ipex/admit") + assert geda_admit_note_a["a"]["d"] == geda_admit_note_b["a"]["d"] + + qvi_received_qvi_a, qvi_received_qvi_b = wait_for_multisig_received_credential( + qvi_client_a, + qvi_client_b, + qvi_creder_a.said, + ) + + # Stage 3: QVI becomes issuer and grants the chained LE credential to the + # LE group using the received QVI credential as the `qvi` source edge. + qvi_registry_nonce = coring.randomNonce() + qvi_registry_operation_a, _ = create_multisig_registry( + qvi_client_a, + local_member_name=qvi_member_a_name, + group_name=qvi_group_name, + other_member_prefixes=[qvi_member_b["prefix"]], + registry_name=qvi_registry_name, + nonce=qvi_registry_nonce, + is_initiator=True, + ) + _, qvi_registry_request_b = wait_for_multisig_request(qvi_client_b, "/multisig/vcp") + qvi_registry_operation_b, _ = create_multisig_registry( + qvi_client_b, + local_member_name=qvi_member_b_name, + group_name=qvi_group_name, + other_member_prefixes=[qvi_member_a["prefix"]], + registry_name=qvi_registry_name, + nonce=qvi_registry_nonce, + request=qvi_registry_request_b, + ) + wait_for_operation(qvi_client_a, qvi_registry_operation_a) + wait_for_operation(qvi_client_b, qvi_registry_operation_b) + qvi_registry_a, qvi_registry_b = wait_for_multisig_registry_convergence( + qvi_client_a, + qvi_client_b, + group_name=qvi_group_name, + registry_name=qvi_registry_name, + ) + + le_issue_timestamp = helping.nowIso8601() + le_creder_a, le_iss_a, le_anc_a, le_sigs_a, le_issue_operation_a, _ = issue_multisig_credential( + qvi_client_a, + local_member_name=qvi_member_a_name, + group_name=qvi_group_name, + other_member_prefixes=[qvi_member_b["prefix"]], + registry_name=qvi_registry_name, + recipient=le_group_a["prefix"], + data=LE_DATA, + schema=ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"], + edges=_source_edges("qvi", qvi_received_qvi_a), + rules=_le_rules(), + timestamp=le_issue_timestamp, + is_initiator=True, + ) + _, le_issue_request_b = wait_for_multisig_request(qvi_client_b, "/multisig/iss") + le_creder_b, le_iss_b, le_anc_b, le_sigs_b, le_issue_operation_b, _ = issue_multisig_credential( + qvi_client_b, + local_member_name=qvi_member_b_name, + group_name=qvi_group_name, + other_member_prefixes=[qvi_member_a["prefix"]], + registry_name=qvi_registry_name, + recipient=le_group_a["prefix"], + data=LE_DATA, + schema=ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"], + edges=_source_edges("qvi", qvi_received_qvi_b), + rules=_le_rules(), + timestamp=le_issue_timestamp, + request=le_issue_request_b, + ) + wait_for_operation(qvi_client_a, le_issue_operation_a) + wait_for_operation(qvi_client_b, le_issue_operation_b) + wait_for_issued_credential(qvi_client_a, qvi_group_a["prefix"], le_creder_a.said) + wait_for_issued_credential(qvi_client_b, qvi_group_b["prefix"], le_creder_a.said) + + le_grant_timestamp = helping.nowIso8601() + le_grant_operation_a = send_multisig_credential_grant( + qvi_client_a, + local_member_name=qvi_member_a_name, + group_name=qvi_group_name, + other_member_prefixes=[qvi_member_b["prefix"]], + recipient=le_group_a["prefix"], + creder=le_creder_a, + iserder=le_iss_a, + anc=le_anc_a, + sigs=le_sigs_a, + timestamp=le_grant_timestamp, + is_initiator=True, + ) + le_grant_operation_b = send_multisig_credential_grant( + qvi_client_b, + local_member_name=qvi_member_b_name, + group_name=qvi_group_name, + other_member_prefixes=[qvi_member_a["prefix"]], + recipient=le_group_a["prefix"], + creder=le_creder_b, + iserder=le_iss_b, + anc=le_anc_b, + sigs=le_sigs_b, + timestamp=le_grant_timestamp, + ) + wait_for_operation(qvi_client_a, le_grant_operation_a) + wait_for_operation(qvi_client_b, le_grant_operation_b) + + le_grant_client_index, le_grant_note = wait_for_notification_any( + [le_client_a, le_client_b], + "/exn/ipex/grant", + ) + le_grant_said = le_grant_note["a"]["d"] + le_admit_timestamp = helping.nowIso8601() + if le_grant_client_index == 0: + first_le_client, first_le_member_name, first_le_peer_prefixes = ( + le_client_a, + le_member_a_name, + [le_member_b["prefix"]], + ) + second_le_client, second_le_member_name, second_le_peer_prefixes = ( + le_client_b, + le_member_b_name, + [le_member_a["prefix"]], + ) + else: + first_le_client, first_le_member_name, first_le_peer_prefixes = ( + le_client_b, + le_member_b_name, + [le_member_a["prefix"]], + ) + second_le_client, second_le_member_name, second_le_peer_prefixes = ( + le_client_a, + le_member_a_name, + [le_member_b["prefix"]], + ) + first_le_admit_operation = submit_multisig_admit( + first_le_client, + local_member_name=first_le_member_name, + group_name=le_group_name, + other_member_prefixes=first_le_peer_prefixes, + issuer_prefix=qvi_group_a["prefix"], + grant_said=le_grant_said, + timestamp=le_admit_timestamp, + ) + wait_for_exchange(second_le_client, le_grant_said, expected_route="/ipex/grant") + second_le_admit_operation = submit_multisig_admit( + second_le_client, + local_member_name=second_le_member_name, + group_name=le_group_name, + other_member_prefixes=second_le_peer_prefixes, + issuer_prefix=qvi_group_a["prefix"], + grant_said=le_grant_said, + timestamp=le_admit_timestamp, + ) + wait_for_operation(first_le_client, first_le_admit_operation) + wait_for_operation(second_le_client, second_le_admit_operation) + + le_issuer_admit_note_a = wait_for_notification(qvi_client_a, "/exn/ipex/admit") + le_issuer_admit_note_b = wait_for_notification(qvi_client_b, "/exn/ipex/admit") + assert le_issuer_admit_note_a["a"]["d"] == le_issuer_admit_note_b["a"]["d"] + + le_received_a, le_received_b = wait_for_multisig_received_credential( + le_client_a, + le_client_b, + le_creder_a.said, + ) + + # Stage 4: LE becomes issuer and grants OOR Auth back to the QVI group. + # This hop is intentionally non-obvious: subject `a.i` is the QVI group, + # while `AID` names the eventual single-sig role holder. + le_registry_nonce = coring.randomNonce() + le_registry_operation_a, _ = create_multisig_registry( + le_client_a, + local_member_name=le_member_a_name, + group_name=le_group_name, + other_member_prefixes=[le_member_b["prefix"]], + registry_name=le_registry_name, + nonce=le_registry_nonce, + is_initiator=True, + ) + _, le_registry_request_b = wait_for_multisig_request(le_client_b, "/multisig/vcp") + le_registry_operation_b, _ = create_multisig_registry( + le_client_b, + local_member_name=le_member_b_name, + group_name=le_group_name, + other_member_prefixes=[le_member_a["prefix"]], + registry_name=le_registry_name, + nonce=le_registry_nonce, + request=le_registry_request_b, + ) + wait_for_operation(le_client_a, le_registry_operation_a) + wait_for_operation(le_client_b, le_registry_operation_b) + le_registry_a, le_registry_b = wait_for_multisig_registry_convergence( + le_client_a, + le_client_b, + group_name=le_group_name, + registry_name=le_registry_name, + ) + + oor_auth_data = dict(OOR_DATA, AID=role_holder["prefix"]) + oor_auth_timestamp = helping.nowIso8601() + oor_auth_creder_a, oor_auth_iss_a, oor_auth_anc_a, oor_auth_sigs_a, oor_auth_operation_a, _ = ( + issue_multisig_credential( + le_client_a, + local_member_name=le_member_a_name, + group_name=le_group_name, + other_member_prefixes=[le_member_b["prefix"]], + registry_name=le_registry_name, + recipient=qvi_group_a["prefix"], + data=oor_auth_data, + schema=ADDITIONAL_SCHEMA_OOBI_SAIDS["oor-auth"], + edges=_source_edges("le", le_received_a), + rules=_le_rules(), + timestamp=oor_auth_timestamp, + is_initiator=True, + ) + ) + _, oor_auth_request_b = wait_for_multisig_request(le_client_b, "/multisig/iss") + oor_auth_creder_b, oor_auth_iss_b, oor_auth_anc_b, oor_auth_sigs_b, oor_auth_operation_b, _ = issue_multisig_credential( + le_client_b, + local_member_name=le_member_b_name, + group_name=le_group_name, + other_member_prefixes=[le_member_a["prefix"]], + registry_name=le_registry_name, + recipient=qvi_group_a["prefix"], + data=oor_auth_data, + schema=ADDITIONAL_SCHEMA_OOBI_SAIDS["oor-auth"], + edges=_source_edges("le", le_received_b), + rules=_le_rules(), + timestamp=oor_auth_timestamp, + request=oor_auth_request_b, + ) + wait_for_operation(le_client_a, oor_auth_operation_a) + wait_for_operation(le_client_b, oor_auth_operation_b) + wait_for_issued_credential(le_client_a, le_group_a["prefix"], oor_auth_creder_a.said) + wait_for_issued_credential(le_client_b, le_group_b["prefix"], oor_auth_creder_a.said) + + # As above, finish the entire OOR Auth grant wave before any QVI member + # starts the matching admit wave. + oor_auth_grant_timestamp = helping.nowIso8601() + oor_auth_grant_operation_a = send_multisig_credential_grant( + le_client_a, + local_member_name=le_member_a_name, + group_name=le_group_name, + other_member_prefixes=[le_member_b["prefix"]], + recipient=qvi_group_a["prefix"], + creder=oor_auth_creder_a, + iserder=oor_auth_iss_a, + anc=oor_auth_anc_a, + sigs=oor_auth_sigs_a, + timestamp=oor_auth_grant_timestamp, + is_initiator=True, + ) + oor_auth_grant_operation_b = send_multisig_credential_grant( + le_client_b, + local_member_name=le_member_b_name, + group_name=le_group_name, + other_member_prefixes=[le_member_a["prefix"]], + recipient=qvi_group_a["prefix"], + creder=oor_auth_creder_b, + iserder=oor_auth_iss_b, + anc=oor_auth_anc_b, + sigs=oor_auth_sigs_b, + timestamp=oor_auth_grant_timestamp, + ) + wait_for_operation(le_client_a, oor_auth_grant_operation_a) + wait_for_operation(le_client_b, oor_auth_grant_operation_b) + + oor_auth_grant_client_index, oor_auth_grant_note = wait_for_notification_any( + [qvi_client_a, qvi_client_b], + "/exn/ipex/grant", + ) + oor_auth_grant_said = oor_auth_grant_note["a"]["d"] + oor_auth_admit_timestamp = helping.nowIso8601() + if oor_auth_grant_client_index == 0: + first_oor_auth_client, first_oor_auth_member_name, first_oor_auth_peer_prefixes = ( + qvi_client_a, + qvi_member_a_name, + [qvi_member_b["prefix"]], + ) + second_oor_auth_client, second_oor_auth_member_name, second_oor_auth_peer_prefixes = ( + qvi_client_b, + qvi_member_b_name, + [qvi_member_a["prefix"]], + ) + else: + first_oor_auth_client, first_oor_auth_member_name, first_oor_auth_peer_prefixes = ( + qvi_client_b, + qvi_member_b_name, + [qvi_member_a["prefix"]], + ) + second_oor_auth_client, second_oor_auth_member_name, second_oor_auth_peer_prefixes = ( + qvi_client_a, + qvi_member_a_name, + [qvi_member_b["prefix"]], + ) + first_oor_auth_admit_operation = submit_multisig_admit( + first_oor_auth_client, + local_member_name=first_oor_auth_member_name, + group_name=qvi_group_name, + other_member_prefixes=first_oor_auth_peer_prefixes, + issuer_prefix=le_group_a["prefix"], + grant_said=oor_auth_grant_said, + timestamp=oor_auth_admit_timestamp, + ) + wait_for_exchange(second_oor_auth_client, oor_auth_grant_said, expected_route="/ipex/grant") + second_oor_auth_admit_operation = submit_multisig_admit( + second_oor_auth_client, + local_member_name=second_oor_auth_member_name, + group_name=qvi_group_name, + other_member_prefixes=second_oor_auth_peer_prefixes, + issuer_prefix=le_group_a["prefix"], + grant_said=oor_auth_grant_said, + timestamp=oor_auth_admit_timestamp, + ) + wait_for_operation(first_oor_auth_client, first_oor_auth_admit_operation) + wait_for_operation(second_oor_auth_client, second_oor_auth_admit_operation) + + oor_auth_issuer_admit_note_a = wait_for_notification(le_client_a, "/exn/ipex/admit") + oor_auth_issuer_admit_note_b = wait_for_notification(le_client_b, "/exn/ipex/admit") + assert oor_auth_issuer_admit_note_a["a"]["d"] == oor_auth_issuer_admit_note_b["a"]["d"] + + qvi_received_oor_auth_a, qvi_received_oor_auth_b = wait_for_multisig_received_credential( + qvi_client_a, + qvi_client_b, + oor_auth_creder_a.said, + ) + + # Stage 5: QVI issues the final OOR credential to the single-sig person + # holder, chaining it to the received OOR Auth credential under `auth`. + oor_timestamp = helping.nowIso8601() + oor_creder_a, oor_iss_a, oor_anc_a, oor_sigs_a, oor_operation_a, _ = issue_multisig_credential( + qvi_client_a, + local_member_name=qvi_member_a_name, + group_name=qvi_group_name, + other_member_prefixes=[qvi_member_b["prefix"]], + registry_name=qvi_registry_name, + recipient=role_holder["prefix"], + data=OOR_DATA, + schema=ADDITIONAL_SCHEMA_OOBI_SAIDS["oor"], + edges=_source_edges("auth", qvi_received_oor_auth_a, operator="I2I"), + rules=_le_rules(), + timestamp=oor_timestamp, + is_initiator=True, + ) + _, oor_request_b = wait_for_multisig_request(qvi_client_b, "/multisig/iss") + oor_creder_b, oor_iss_b, oor_anc_b, oor_sigs_b, oor_operation_b, _ = issue_multisig_credential( + qvi_client_b, + local_member_name=qvi_member_b_name, + group_name=qvi_group_name, + other_member_prefixes=[qvi_member_a["prefix"]], + registry_name=qvi_registry_name, + recipient=role_holder["prefix"], + data=OOR_DATA, + schema=ADDITIONAL_SCHEMA_OOBI_SAIDS["oor"], + edges=_source_edges("auth", qvi_received_oor_auth_b, operator="I2I"), + rules=_le_rules(), + timestamp=oor_timestamp, + request=oor_request_b, + ) + wait_for_operation(qvi_client_a, oor_operation_a) + wait_for_operation(qvi_client_b, oor_operation_b) + wait_for_issued_credential(qvi_client_a, qvi_group_a["prefix"], oor_creder_a.said) + wait_for_issued_credential(qvi_client_b, qvi_group_b["prefix"], oor_creder_a.said) + + oor_grant_timestamp = helping.nowIso8601() + oor_grant_operation_a = send_multisig_credential_grant( + qvi_client_a, + local_member_name=qvi_member_a_name, + group_name=qvi_group_name, + other_member_prefixes=[qvi_member_b["prefix"]], + recipient=role_holder["prefix"], + creder=oor_creder_a, + iserder=oor_iss_a, + anc=oor_anc_a, + sigs=oor_sigs_a, + timestamp=oor_grant_timestamp, + is_initiator=True, + ) + oor_grant_operation_b = send_multisig_credential_grant( + qvi_client_b, + local_member_name=qvi_member_b_name, + group_name=qvi_group_name, + other_member_prefixes=[qvi_member_a["prefix"]], + recipient=role_holder["prefix"], + creder=oor_creder_b, + iserder=oor_iss_b, + anc=oor_anc_b, + sigs=oor_sigs_b, + timestamp=oor_grant_timestamp, + ) + wait_for_operation(qvi_client_a, oor_grant_operation_a) + wait_for_operation(qvi_client_b, oor_grant_operation_b) + + # The final holder is single-sig, so the last hop uses the ordinary admit + # helper after the multisig QVI grant wave has fully completed. + role_grant_note = wait_for_notification(role_client, "/exn/ipex/grant") + submit_admit( + role_client, + holder_name=role_holder_name, + issuer_prefix=qvi_group_a["prefix"], + notification=role_grant_note, + ) + wait_for_notification(qvi_client_a, "/exn/ipex/admit") + wait_for_notification(qvi_client_b, "/exn/ipex/admit") + oor_received = wait_for_credential(role_client, oor_creder_a.said) + + assert geda_group_a["prefix"] == geda_group_b["prefix"] + assert geda_registry_a["regk"] == geda_registry_b["regk"] + assert qvi_registry_a["regk"] == qvi_registry_b["regk"] + assert le_registry_a["regk"] == le_registry_b["regk"] + assert qvi_creder_a.said == qvi_creder_b.said + assert le_creder_a.said == le_creder_b.said + assert oor_auth_creder_a.said == oor_auth_creder_b.said + assert oor_creder_a.said == oor_creder_b.said + + _assert_holder_read_surface( + qvi_client_a, + issuer_prefix=geda_group_a["prefix"], + holder_prefix=qvi_group_a["prefix"], + said=qvi_creder_a.said, + schema_said=QVI_SCHEMA_SAID, + ) + _assert_holder_read_surface( + qvi_client_b, + issuer_prefix=geda_group_b["prefix"], + holder_prefix=qvi_group_b["prefix"], + said=qvi_creder_a.said, + schema_said=QVI_SCHEMA_SAID, + ) + _assert_holder_read_surface( + le_client_a, + issuer_prefix=qvi_group_a["prefix"], + holder_prefix=le_group_a["prefix"], + said=le_creder_a.said, + schema_said=ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"], + ) + _assert_holder_read_surface( + le_client_b, + issuer_prefix=qvi_group_b["prefix"], + holder_prefix=le_group_b["prefix"], + said=le_creder_a.said, + schema_said=ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"], + ) + _assert_holder_read_surface( + qvi_client_a, + issuer_prefix=le_group_a["prefix"], + holder_prefix=qvi_group_a["prefix"], + said=oor_auth_creder_a.said, + schema_said=ADDITIONAL_SCHEMA_OOBI_SAIDS["oor-auth"], + ) + _assert_holder_read_surface( + qvi_client_b, + issuer_prefix=le_group_b["prefix"], + holder_prefix=qvi_group_b["prefix"], + said=oor_auth_creder_a.said, + schema_said=ADDITIONAL_SCHEMA_OOBI_SAIDS["oor-auth"], + ) + _assert_holder_read_surface( + role_client, + issuer_prefix=qvi_group_a["prefix"], + holder_prefix=role_holder["prefix"], + said=oor_creder_a.said, + schema_said=ADDITIONAL_SCHEMA_OOBI_SAIDS["oor"], + assert_subject_filter=False, + ) + assert oor_received["sad"]["d"] == oor_creder_a.said + assert le_received_a["sad"]["e"]["qvi"]["n"] == qvi_creder_a.said + assert le_received_b["sad"]["e"]["qvi"]["n"] == qvi_creder_a.said + assert le_received_a["chains"][0]["sad"]["d"] == qvi_creder_a.said + assert le_received_b["chains"][0]["sad"]["d"] == qvi_creder_a.said + assert qvi_received_oor_auth_a["sad"]["e"]["le"]["n"] == le_creder_a.said + assert qvi_received_oor_auth_b["sad"]["e"]["le"]["n"] == le_creder_a.said + assert qvi_received_oor_auth_a["chains"][0]["sad"]["d"] == le_creder_a.said + assert qvi_received_oor_auth_b["chains"][0]["sad"]["d"] == le_creder_a.said + assert oor_received["sad"]["e"]["auth"]["n"] == oor_auth_creder_a.said + assert oor_received["sad"]["e"]["auth"]["o"] == "I2I" + assert oor_received["chains"][0]["sad"]["d"] == oor_auth_creder_a.said + assert qvi_received_oor_auth_a["sad"]["a"]["i"] == qvi_group_a["prefix"] + assert qvi_received_oor_auth_a["sad"]["a"]["AID"] == role_holder["prefix"] + assert qvi_received_oor_auth_b["sad"]["a"]["i"] == qvi_group_b["prefix"] + assert qvi_received_oor_auth_b["sad"]["a"]["AID"] == role_holder["prefix"] + + _assert_filtered_contains( + geda_client_a, + schema_said=QVI_SCHEMA_SAID, + subject_prefix=qvi_group_a["prefix"], + said=qvi_creder_a.said, + ) + _assert_filtered_contains( + geda_client_b, + schema_said=QVI_SCHEMA_SAID, + subject_prefix=qvi_group_b["prefix"], + said=qvi_creder_a.said, + ) + _assert_filtered_contains( + qvi_client_a, + schema_said=ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"], + subject_prefix=le_group_a["prefix"], + said=le_creder_a.said, + ) + _assert_filtered_contains( + qvi_client_b, + schema_said=ADDITIONAL_SCHEMA_OOBI_SAIDS["legal-entity"], + subject_prefix=le_group_b["prefix"], + said=le_creder_a.said, + ) + _assert_filtered_contains( + le_client_a, + schema_said=ADDITIONAL_SCHEMA_OOBI_SAIDS["oor-auth"], + subject_prefix=qvi_group_a["prefix"], + said=oor_auth_creder_a.said, + ) + _assert_filtered_contains( + le_client_b, + schema_said=ADDITIONAL_SCHEMA_OOBI_SAIDS["oor-auth"], + subject_prefix=qvi_group_b["prefix"], + said=oor_auth_creder_a.said, + ) + _assert_filtered_contains( + qvi_client_a, + schema_said=ADDITIONAL_SCHEMA_OOBI_SAIDS["oor"], + subject_prefix=role_holder["prefix"], + said=oor_creder_a.said, + ) + _assert_filtered_contains( + qvi_client_b, + schema_said=ADDITIONAL_SCHEMA_OOBI_SAIDS["oor"], + subject_prefix=role_holder["prefix"], + said=oor_creder_a.said, + ) diff --git a/tests/integration/test_provisioning_and_identifiers.py b/tests/integration/test_provisioning_and_identifiers.py index c549206..1ac1477 100644 --- a/tests/integration/test_provisioning_and_identifiers.py +++ b/tests/integration/test_provisioning_and_identifiers.py @@ -5,9 +5,7 @@ from __future__ import annotations import pytest -from keri.help import helping - -from .constants import SCHEMA_SAID, TEST_WITNESS_AIDS +from .constants import QVI_SCHEMA_SAID, TEST_WITNESS_AIDS from .helpers import ( additional_schema_oobis, alias, @@ -101,15 +99,15 @@ def test_schema_oobi_resolution_smoke(client_factory): # newly created identifier. client = client_factory() - result = resolve_schema_oobi(client) - schema = client.schemas().get(SCHEMA_SAID) + result = resolve_schema_oobi(client, QVI_SCHEMA_SAID) + schema = client.schemas().get(QVI_SCHEMA_SAID) schemas = client.schemas().list() schema_saids = {entry["$id"] for entry in schemas} assert result["done"] is True - assert result["metadata"]["oobi"] == schema_oobi(client) - assert schema["$id"] == SCHEMA_SAID - assert SCHEMA_SAID in schema_saids + assert result["metadata"]["oobi"] == schema_oobi(client, QVI_SCHEMA_SAID) + assert schema["$id"] == QVI_SCHEMA_SAID + assert QVI_SCHEMA_SAID in schema_saids for alias_name, oobi in additional_schema_oobis(client).items(): extra = resolve_oobi(client, oobi, alias=alias_name) @@ -266,7 +264,7 @@ def test_credential_issue_smoke(client_factory): registry_name = alias("registry") issuer_hab = create_identifier(client, issuer_name, wits=[]) - resolve_schema_oobi(client) + resolve_schema_oobi(client, QVI_SCHEMA_SAID) registry_result = client.registries().create(issuer_name, registry_name) wait_for_operation(client, registry_result.op()) @@ -278,15 +276,15 @@ def test_credential_issue_smoke(client_factory): assert registry["name"] == registry_name data = {"LEI": "5493001KJTIIGC8Y1R17"} - creder, _, _, _, op = client.credentials().create( - issuer_hab, - registry=registry, + issue_result = client.credentials().issue( + issuer_name, + registry_name, data=data, - schema=SCHEMA_SAID, + schema=QVI_SCHEMA_SAID, recipient=issuer_hab["prefix"], - timestamp=helping.nowIso8601(), ) - wait_for_operation(client, op) + wait_for_operation(client, issue_result.op()) + creder = issue_result.acdc credentials = client.credentials().list() credential = next(entry for entry in credentials if entry["sad"]["d"] == creder.said) @@ -295,5 +293,5 @@ def test_credential_issue_smoke(client_factory): assert credential["sad"]["d"] == creder.said assert credential["sad"]["i"] == issuer_hab["prefix"] assert credential["sad"]["a"]["i"] == issuer_hab["prefix"] - assert credential["sad"]["s"] == SCHEMA_SAID + assert credential["sad"]["s"] == QVI_SCHEMA_SAID assert exported diff --git a/tests/integration/topology.py b/tests/integration/topology.py index c8e9cf2..8d5110d 100644 --- a/tests/integration/topology.py +++ b/tests/integration/topology.py @@ -8,7 +8,7 @@ import secrets import socket -from .constants import ADDITIONAL_SCHEMA_OOBI_SAIDS, SCHEMA_SAID, WITNESS_AIDS +from .constants import ADDITIONAL_SCHEMA_OOBI_SAIDS, QVI_SCHEMA_SAID, WITNESS_AIDS def _slug(text: str) -> str: @@ -79,7 +79,7 @@ def vlei_schema_url(self) -> str: @property def schema_oobi(self) -> str: - return f"{self.vlei_schema_url}/oobi/{SCHEMA_SAID}" + return f"{self.vlei_schema_url}/oobi/{QVI_SCHEMA_SAID}" @property def witness_oobis(self) -> list[str]: