diff --git a/src/keria/app/aiding.py b/src/keria/app/aiding.py index 5eaede13..6ab19a4f 100644 --- a/src/keria/app/aiding.py +++ b/src/keria/app/aiding.py @@ -23,6 +23,7 @@ from marshmallow_dataclass import class_schema from ..core import longrunning, httping +from .multisig import replay_multisig_embeds from ..utils.openapi import namedtupleToEnum, dataclassFromFielddom from keri.core.serdering import Protocols, Vrsn_1_0, Vrsn_2_0, SerderKERI @@ -1659,6 +1660,13 @@ def on_post(req, rep, name, aid=None, role=None): coring.Saider(qb64=hab.kever.serder.said), rsigers, ) + replay_multisig_embeds( + agent, + hab, + route="/multisig/rpy", + embeds=dict(rpy=rserder.ked), + labels=("rpy",), + ) try: agent.hby.rvy.processReply(rserder, tsgs=[tsg]) except kering.UnverifiedReplyError: diff --git a/src/keria/app/credentialing.py b/src/keria/app/credentialing.py index e05f89b7..4acb507f 100644 --- a/src/keria/app/credentialing.py +++ b/src/keria/app/credentialing.py @@ -21,6 +21,7 @@ from ..utils.openapi import dataclassFromFielddom from keri.core.serdering import Protocols, Vrsn_1_0, Vrsn_2_0, SerderKERI from ..core import httping, longrunning +from .multisig import replay_multisig_embeds from marshmallow import fields, Schema as MarshmallowSchema from typing import List, Dict, Any, Optional, Tuple, Literal, Union from .aiding import ( @@ -393,6 +394,17 @@ def on_post(self, req, rep, name): registry = agent.rgy.makeSignifyRegistry(name=rname, prefix=hab.pre, regser=vcp) + # The `/multisig/vcp` EXN uses the embed label `anc` for the anchoring + # KEL event. In this endpoint that concrete event is the submitted `ixn`. + protocol_embeds = dict(vcp=vcp.ked, anc=ixn.ked) + replay_multisig_embeds( + agent, + hab, + route="/multisig/vcp", + embeds=protocol_embeds, + labels=("anc",), + ) + if hab.kever.estOnly: op = self.identifierResource.rotate(agent, name, body) else: @@ -951,6 +963,14 @@ def on_post(self, req, rep, name): description=f"issue against invalid registry SAID {regk}" ) + replay_multisig_embeds( + agent, + hab, + route="/multisig/iss", + embeds=dict(acdc=creder.sad, iss=iserder.ked, anc=anc.ked), + labels=("anc", "acdc"), + ) + if hab.kever.estOnly: op = self.identifierResource.rotate(agent, name, body) else: @@ -1245,15 +1265,26 @@ def on_delete(self, req, rep, name, said): description=f"credential for said {said} not found." ) + if hab.kever.estOnly: + anc = serdering.SerderKERI(sad=httping.getRequiredParam(body, "rot")) + else: + anc = serdering.SerderKERI(sad=httping.getRequiredParam(body, "ixn")) + + replay_multisig_embeds( + agent, + hab, + route="/multisig/rev", + embeds=dict(rev=rserder.ked, anc=anc.ked), + labels=("anc",), + ) + if hab.kever.estOnly: op = self.identifierResource.rotate(agent, name, body) - anc = httping.getRequiredParam(body, "rot") else: op = self.identifierResource.interact(agent, name, body) - anc = httping.getRequiredParam(body, "ixn") try: - agent.registrar.revoke(regk, rserder, anc) + agent.registrar.revoke(regk, rserder, anc.ked) except Exception: raise falcon.HTTPBadRequest(description="invalid revocation event.") @@ -1392,6 +1423,10 @@ def incept(self, hab, registry, prefixer=None, seqner=None, saider=None): ) else: + # assume SignifyGroupHab - multisig incept, so start the counselor to wait on multisig incept completion + self.counselor.start( + ghab=hab, prefixer=prefixer, seqner=seqner, saider=saider + ) logger.info( "[%s | %s]: Waiting for TEL registry vcp event mulisig anchoring event", hab.name, @@ -1443,6 +1478,9 @@ def issue(self, regk, iserder, anc): seqner = coring.Seqner(sn=sn) saider = coring.Saider(qb64=said) + self.counselor.start( + prefixer=prefixer, seqner=seqner, saider=saider, ghab=hab + ) logger.info( "[%s | %s]: Waiting for TEL iss event mulisig anchoring event %s", hab.name, diff --git a/src/keria/app/multisig.py b/src/keria/app/multisig.py new file mode 100644 index 00000000..e4c3b0cd --- /dev/null +++ b/src/keria/app/multisig.py @@ -0,0 +1,60 @@ +# -*- encoding: utf-8 -*- +""" +KERIA +keria.app.multisig module + +Shared helpers for multisig approval flows. +""" + +from keri.app.habbing import SignifyGroupHab +from keri.core import coring + + +def replay_multisig_embeds(agent, hab, *, route, embeds, labels): + """Replay stored non-local multisig embedded events before local approval. + + Signify follower approval can happen after the matching multisig EXN was + already received and stored. In that local-after-remote ordering, KLI + explicitly replays the remote participant's signed embedded event streams + before it parses the follower's approval of the same proposal. + + This helper restores that ordering for KERIA approval endpoints. It derives + the embedded-section SAID from the local approval payload, loads matching + non-local multisig EXNs from the mux, and replays the selected embedded + labels plus their pathed attachments through the parser. + + The ``labels`` argument uses EXN embed-label names, not concrete KEL/TEL + event-type names. For example, the multisig protocol uses the embed label + ``anc`` for the anchoring KEL event even when the concrete event is an + interaction event (``ixn``). + """ + if not isinstance(hab, SignifyGroupHab): + return 0 + + embed_ked = dict(embeds) + embed_ked["d"] = "" + _, embed_ked = coring.Saider.saidify(sad=embed_ked, label=coring.Saids.d) + + replays = 0 + for msg in agent.mux.get(esaid=embed_ked["d"]): + exn = msg["exn"] + if exn["r"] != route or exn["i"] == hab.mhab.pre: + continue + + paths = msg["paths"] + for label in labels: + if label not in paths: + continue + + sadder = coring.Sadder(ked=embeds[label]) + attachment = paths[label] + if isinstance(attachment, str): + attachment = attachment.encode("utf-8") + + ims = bytearray(sadder.raw) + ims.extend(attachment) + agent.hby.psr.parseOne(ims=ims) + + replays += 1 + + return replays diff --git a/tests/app/test_aiding.py b/tests/app/test_aiding.py index c8299057..84992303 100644 --- a/tests/app/test_aiding.py +++ b/tests/app/test_aiding.py @@ -152,6 +152,61 @@ def test_endrole_ends(helpers): } +def test_endrole_replays_multisig_embeds_before_local_reply_processing( + helpers, monkeypatch +): + """Follower B must replay A's stored `/multisig/rpy` before endorsing. + + This is an endpoint-order test, not a full multisig integration test. + + Identity mapping: + + - participant A is represented by the mocked `replay_multisig_embeds(...)` + call, which stands in for the already-stored remote `/multisig/rpy`. + - participant B is the local `user1` habitat handled by this KERIA agent. + - `rpy` is B's local approval payload for the same end-role authorization. + + The important assertion is ordering: KERIA must replay A's stored reply + stream before B processes B's own local reply. + """ + with helpers.openKeria() as (agency, agent, app, client): + end = aiding.IdentifierCollectionEnd() + app.add_route("/identifiers", end) + idResEnd = aiding.IdentifierResourceEnd() + app.add_route("/identifiers/{name}", idResEnd) + endRolesEnd = aiding.EndRoleCollectionEnd() + app.add_route("/identifiers/{name}/endroles", endRolesEnd) + + # Participant B's local AID and the reply payload B later submits to + # KERIA when B approves the multisig end-role proposal. + salt = b"0123456789abcdef" + op = helpers.createAid(client, "user1", salt) + aid = op["response"] + rpy = helpers.endrole(aid["i"], agent.agentHab.pre) + sigs = helpers.sign(salt, 0, 0, rpy.raw) + + calls = [] + + # This stands in for participant A's earlier `/multisig/rpy` arrival. + # We only care that the follower approval path invokes replay first. + def fake_replay(*args, **kwargs): + calls.append(("replay", kwargs["route"], kwargs["labels"])) + + def fake_process_reply(*args, **kwargs): + calls.append("processReply") + + monkeypatch.setattr(aiding, "replay_multisig_embeds", fake_replay) + monkeypatch.setattr(agent.hby.rvy, "processReply", fake_process_reply) + + res = client.simulate_post( + path="/identifiers/user1/endroles", + body=json.dumps(dict(rpy=rpy.ked, sigs=sigs)).encode("utf-8"), + ) + + assert res.status_code == 202 + assert calls[:2] == [("replay", "/multisig/rpy", ("rpy",)), "processReply"] + + def test_locscheme_ends(helpers, mockHelpingNowUTC): with helpers.openKeria() as (agency, agent, app, client): locSchemesEnd = aiding.LocSchemeCollectionEnd() diff --git a/tests/app/test_credentialing.py b/tests/app/test_credentialing.py index ae9eb76a..fa6fa789 100644 --- a/tests/app/test_credentialing.py +++ b/tests/app/test_credentialing.py @@ -7,6 +7,7 @@ """ import json +from types import SimpleNamespace import falcon from falcon import testing @@ -312,120 +313,124 @@ def test_issue_credential(helpers, seeder): seeder.seedSchema(agent.hby.db) seeder.seedSchema(agent1.hby.db) - # create the server that will receive the credential issuance messages - serverDoer = helpers.server(agency) - tock = 0.03125 limit = 1.0 doist = doing.Doist(limit=limit, tock=tock, real=True) - deeds = doist.enter(doers=[agent, serverDoer]) - - isalt = b"0123456789abcdef" - registry, issuer = helpers.createRegistry(client, agent, isalt, doist, deeds) - - iaid = issuer["prefix"] - idig = issuer["state"]["d"] - - rsalt = b"abcdef0123456789" - op = helpers.createAid(client, "recipient", rsalt) - aid = op["response"] - recp = aid["i"] - assert recp == "EMgdjM1qALk3jlh4P2YyLRSTcjSOjLXD3e_uYpxbdbg6" - - helpers.createEndRole(client, agent, recp, "recipient", rsalt) - - dt = "2021-01-01T00:00:00.000000+00:00" - schema = "EFgnk_c08WmZGgv9_mpldibRuqFMTQN-rAgtD-TCOwbs" - data = dict(LEI="254900DA0GOGCFVWB618", dt=dt) - creder = proving.credential( - issuer=iaid, - schema=schema, - recipient=recp, - data=data, - source={}, - status=registry["regk"], - ) + agent_deeds = doist.enter(doers=[agent]) + verify_deeds = None + try: + isalt = b"0123456789abcdef" + registry, issuer = helpers.createRegistry( + client, agent, isalt, doist, agent_deeds + ) - csigers = helpers.sign(bran=isalt, pidx=0, ridx=0, ser=creder.raw) + iaid = issuer["prefix"] + idig = issuer["state"]["d"] + + rsalt = b"abcdef0123456789" + op = helpers.createAid(client, "recipient", rsalt) + aid = op["response"] + recp = aid["i"] + assert recp == "EMgdjM1qALk3jlh4P2YyLRSTcjSOjLXD3e_uYpxbdbg6" + + helpers.createEndRole(client, agent, recp, "recipient", rsalt) + + dt = "2021-01-01T00:00:00.000000+00:00" + schema = "EFgnk_c08WmZGgv9_mpldibRuqFMTQN-rAgtD-TCOwbs" + data = dict(LEI="254900DA0GOGCFVWB618", dt=dt) + creder = proving.credential( + issuer=iaid, + schema=schema, + recipient=recp, + data=data, + source={}, + status=registry["regk"], + ) - # Test no backers... backers would use backerIssue - regser = eventing.issue(vcdig=creder.said, regk=registry["regk"], dt=dt) + csigers = helpers.sign(bran=isalt, pidx=0, ridx=0, ser=creder.raw) - anchor = dict(i=regser.ked["i"], s=regser.ked["s"], d=regser.said) - serder, sigers = helpers.interact( - pre=iaid, bran=isalt, pidx=0, ridx=0, dig=idig, sn="2", data=[anchor] - ) + # Test no backers... backers would use backerIssue + regser = eventing.issue(vcdig=creder.said, regk=registry["regk"], dt=dt) - pather = coring.Pather(path=[]) + anchor = dict(i=regser.ked["i"], s=regser.ked["s"], d=regser.said) + serder, sigers = helpers.interact( + pre=iaid, bran=isalt, pidx=0, ridx=0, dig=idig, sn="2", data=[anchor] + ) - body = dict( - iss=regser.ked, - ixn=serder.ked, - sigs=sigers, - acdc=creder.sad, - csigs=csigers, - path=pather.qb64, - ) + pather = coring.Pather(path=[]) - result = client.simulate_post( - path="/identifiers/badname/credentials", - body=json.dumps(body).encode("utf-8"), - ) - assert result.status_code == 404 - assert result.json == { - "description": "badname is not a valid reference to an identifier", - "title": "404 Not Found", - } + body = dict( + iss=regser.ked, + ixn=serder.ked, + sigs=sigers, + acdc=creder.sad, + csigs=csigers, + path=pather.qb64, + ) - result = client.simulate_post( - path="/identifiers/issuer/credentials", - body=json.dumps(body).encode("utf-8"), - ) - op = result.json + result = client.simulate_post( + path="/identifiers/badname/credentials", + body=json.dumps(body).encode("utf-8"), + ) + assert result.status_code == 404 + assert result.json == { + "description": "badname is not a valid reference to an identifier", + "title": "404 Not Found", + } + + result = client.simulate_post( + path="/identifiers/issuer/credentials", + body=json.dumps(body).encode("utf-8"), + ) + op = result.json - assert "ced" in op["metadata"] - assert op["metadata"]["ced"] == creder.sad + assert "ced" in op["metadata"] + assert op["metadata"]["ced"] == creder.sad - while not agent.credentialer.complete(creder.said): - doist.recur(deeds=deeds) + while not agent.credentialer.complete(creder.said): + doist.recur(deeds=agent_deeds) - assert agent.credentialer.complete(creder.said) is True + assert agent.credentialer.complete(creder.said) is True - body["acdc"]["a"]["LEI"] = "ACDC10JSON000197_" - result = client.simulate_post( - path="/identifiers/issuer/credentials", - body=json.dumps(body).encode("utf-8"), - ) - assert result.status_code == 400 + body["acdc"]["a"]["LEI"] = "ACDC10JSON000197_" + result = client.simulate_post( + path="/identifiers/issuer/credentials", + body=json.dumps(body).encode("utf-8"), + ) + assert result.status_code == 400 - # Try to load into another agent after TEL query without IPEX - agent1.parser.parse(ims=agent.hby.habByName("issuer").replay()) - assert iaid in agent1.hby.kevers + # Try to load into another agent after TEL query without IPEX + agent1.parser.parse(ims=agent.hby.habByName("issuer").replay()) + assert iaid in agent1.hby.kevers - agent1.parser.parse(ims=agent.rgy.reger.cloneTvtAt(registry["regk"])) - assert registry["regk"] in agent1.rgy.tevers + agent1.parser.parse(ims=agent.rgy.reger.cloneTvtAt(registry["regk"])) + assert registry["regk"] in agent1.rgy.tevers - agent1.parser.parse(ims=agent.rgy.reger.cloneTvtAt(creder.said)) - assert agent1.rgy.tevers[registry["regk"]].vcSn(creder.said) is not None + agent1.parser.parse(ims=agent.rgy.reger.cloneTvtAt(creder.said)) + assert agent1.rgy.tevers[registry["regk"]].vcSn(creder.said) is not None - credVerifyEnd = credentialing.CredentialVerificationCollectionEnd() - app1.add_route("/credentials/verify", credVerifyEnd) + credVerifyEnd = credentialing.CredentialVerificationCollectionEnd() + app1.add_route("/credentials/verify", credVerifyEnd) - body = dict(acdc=creder.sad, iss=regser.ked) # still has changed LEI - result = client1.simulate_post( - path="/credentials/verify", body=json.dumps(body).encode("utf-8") - ) - assert result.status_code == 400 + body = dict(acdc=creder.sad, iss=regser.ked) # still has changed LEI + result = client1.simulate_post( + path="/credentials/verify", body=json.dumps(body).encode("utf-8") + ) + assert result.status_code == 400 - body["acdc"]["a"]["LEI"] = "254900DA0GOGCFVWB618" # change back - result = client1.simulate_post( - path="/credentials/verify", body=json.dumps(body).encode("utf-8") - ) - assert result.status_code == 202 + body["acdc"]["a"]["LEI"] = "254900DA0GOGCFVWB618" # change back + result = client1.simulate_post( + path="/credentials/verify", body=json.dumps(body).encode("utf-8") + ) + assert result.status_code == 202 - deeds = doist.enter(doers=[agent1]) - while not agent1.rgy.reger.creds.get(keys=(creder.said,)): - doist.recur(deeds=deeds) + verify_deeds = doist.enter(doers=[agent1]) + while not agent1.rgy.reger.creds.get(keys=(creder.said,)): + doist.recur(deeds=verify_deeds) + finally: + if verify_deeds is not None: + doist.exit(deeds=verify_deeds) + doist.exit(deeds=agent_deeds) def test_credentialing_ends(helpers, seeder): @@ -686,179 +691,464 @@ def test_revoke_credential(helpers, seeder): seeder.seedSchema(agent.hby.db) - # create the server that will receive the credential issuance messages - serverDoer = helpers.server(agency) - tock = 0.03125 limit = 1.0 doist = doing.Doist(limit=limit, tock=tock, real=True) - deeds = doist.enter(doers=[agent, serverDoer]) + agent_deeds = doist.enter(doers=[agent]) + try: + isalt = b"0123456789abcdef" + registry, issuer = helpers.createRegistry( + client, agent, isalt, doist, agent_deeds + ) - isalt = b"0123456789abcdef" - registry, issuer = helpers.createRegistry(client, agent, isalt, doist, deeds) + iaid = issuer["prefix"] + idig = issuer["state"]["d"] + + rsalt = b"abcdef0123456789" + op = helpers.createAid(client, "recipient", rsalt) + aid = op["response"] + recp = aid["i"] + assert recp == "EMgdjM1qALk3jlh4P2YyLRSTcjSOjLXD3e_uYpxbdbg6" + + helpers.createEndRole(client, agent, recp, "recipient", rsalt) + + dt = "2021-01-01T00:00:00.000000+00:00" + schema = "EFgnk_c08WmZGgv9_mpldibRuqFMTQN-rAgtD-TCOwbs" + data = dict(LEI="254900DA0GOGCFVWB618", dt=dt) + creder = proving.credential( + issuer=iaid, + schema=schema, + recipient=recp, + data=data, + source={}, + status=registry["regk"], + ) - iaid = issuer["prefix"] - idig = issuer["state"]["d"] + csigers = helpers.sign(bran=isalt, pidx=0, ridx=0, ser=creder.raw) - rsalt = b"abcdef0123456789" - op = helpers.createAid(client, "recipient", rsalt) - aid = op["response"] - recp = aid["i"] - assert recp == "EMgdjM1qALk3jlh4P2YyLRSTcjSOjLXD3e_uYpxbdbg6" - - helpers.createEndRole(client, agent, recp, "recipient", rsalt) - - dt = "2021-01-01T00:00:00.000000+00:00" - schema = "EFgnk_c08WmZGgv9_mpldibRuqFMTQN-rAgtD-TCOwbs" - data = dict(LEI="254900DA0GOGCFVWB618", dt=dt) - creder = proving.credential( - issuer=iaid, - schema=schema, - recipient=recp, - data=data, - source={}, - status=registry["regk"], - ) + # Test no backers... backers would use backerIssue + regser = eventing.issue(vcdig=creder.said, regk=registry["regk"], dt=dt) - csigers = helpers.sign(bran=isalt, pidx=0, ridx=0, ser=creder.raw) + anchor = dict(i=regser.ked["i"], s=regser.ked["s"], d=regser.said) + serder, sigers = helpers.interact( + pre=iaid, bran=isalt, pidx=0, ridx=0, dig=idig, sn="2", data=[anchor] + ) - # Test no backers... backers would use backerIssue - regser = eventing.issue(vcdig=creder.said, regk=registry["regk"], dt=dt) + pather = coring.Pather(path=[]) - anchor = dict(i=regser.ked["i"], s=regser.ked["s"], d=regser.said) - serder, sigers = helpers.interact( - pre=iaid, bran=isalt, pidx=0, ridx=0, dig=idig, sn="2", data=[anchor] - ) + body = dict( + iss=regser.ked, + ixn=serder.ked, + sigs=sigers, + acdc=creder.sad, + csigs=csigers, + path=pather.qb64, + ) - pather = coring.Pather(path=[]) + result = client.simulate_post( + path="/identifiers/badname/credentials", + body=json.dumps(body).encode("utf-8"), + ) + assert result.status_code == 404 + assert result.json == { + "description": "badname is not a valid reference to an identifier", + "title": "404 Not Found", + } + + result = client.simulate_post( + path="/identifiers/issuer/credentials", + body=json.dumps(body).encode("utf-8"), + ) + op = result.json - body = dict( - iss=regser.ked, - ixn=serder.ked, - sigs=sigers, - acdc=creder.sad, - csigs=csigers, - path=pather.qb64, - ) + assert "ced" in op["metadata"] + assert op["metadata"]["ced"] == creder.sad - result = client.simulate_post( - path="/identifiers/badname/credentials", - body=json.dumps(body).encode("utf-8"), - ) - assert result.status_code == 404 - assert result.json == { - "description": "badname is not a valid reference to an identifier", - "title": "404 Not Found", - } + while not agent.credentialer.complete(creder.said): + doist.recur(deeds=agent_deeds) - result = client.simulate_post( - path="/identifiers/issuer/credentials", - body=json.dumps(body).encode("utf-8"), - ) - op = result.json + assert agent.credentialer.complete(creder.said) is True - assert "ced" in op["metadata"] - assert op["metadata"]["ced"] == creder.sad + res = client.simulate_post("/credentials/query") + assert res.status_code == 200 + assert len(res.json) == 1 + assert res.json[0]["sad"]["d"] == creder.said + assert res.json[0]["status"]["s"] == "0" - while not agent.credentialer.complete(creder.said): - doist.recur(deeds=deeds) + res = client.simulate_post("/credentials/query") + assert res.status_code == 200 + assert len(res.json) == 1 + assert res.json[0]["sad"]["d"] == creder.said + assert res.json[0]["status"]["s"] == "0" - assert agent.credentialer.complete(creder.said) is True + regser = eventing.revoke( + vcdig=creder.said, regk=registry["regk"], dig=regser.said, dt=dt + ) + anchor = dict(i=regser.ked["i"], s=regser.ked["s"], d=regser.said) + serder, sigers = helpers.interact( + pre=iaid, + bran=isalt, + pidx=0, + ridx=0, + dig=serder.said, + sn="3", + data=[anchor], + ) - res = client.simulate_post("/credentials/query") - assert res.status_code == 200 - assert len(res.json) == 1 - assert res.json[0]["sad"]["d"] == creder.said - assert res.json[0]["status"]["s"] == "0" + body = dict(rev=regser.ked, ixn=serder.ked, sigs=sigers) + res = client.simulate_delete( + path=f"/identifiers/badname/credentials/{creder.said}", + body=json.dumps(body).encode("utf-8"), + ) + assert res.status_code == 404 + assert res.json == { + "description": "badname is not a valid reference to an identifier", + "title": "404 Not Found", + } + + res = client.simulate_delete( + path=f"/identifiers/issuer/credentials/{regser.said}", + body=json.dumps(body).encode("utf-8"), + ) + assert res.status_code == 404 + assert res.json == { + "description": f"credential for said {regser.said} not found.", + "title": "404 Not Found", + } + + badrev = regser.ked.copy() + badrev["ri"] = "EIVtei3pGKGUw8H2Ri0h1uOevtSA6QGAq5wifbtHIaNI" + _, sad = coring.Saider.saidify(badrev) + + badbody = dict(rev=sad, ixn=serder.ked, sigs=sigers) + res = client.simulate_delete( + path=f"/identifiers/issuer/credentials/{creder.said}", + body=json.dumps(badbody).encode("utf-8"), + ) + assert res.status_code == 404 + assert res.json == { + "description": "revocation against invalid registry SAID " + "EIVtei3pGKGUw8H2Ri0h1uOevtSA6QGAq5wifbtHIaNI", + "title": "404 Not Found", + } + + badrev = regser.ked.copy() + badrev["i"] = "EMgdjM1qALk3jlh4P2YyLRSTcjSOjLXD3e_uYpxbdbg6" + _, sad = coring.Saider.saidify(badrev) + + badbody = dict(rev=sad, ixn=serder.ked, sigs=sigers) + res = client.simulate_delete( + path=f"/identifiers/issuer/credentials/{creder.said}", + body=json.dumps(badbody).encode("utf-8"), + ) + assert res.status_code == 400 + assert res.json == { + "description": "invalid revocation event.", + "title": "400 Bad Request", + } + + res = client.simulate_delete( + path=f"/identifiers/issuer/credentials/{creder.said}", + body=json.dumps(body).encode("utf-8"), + ) + assert res.status_code == 200 - res = client.simulate_post("/credentials/query") + while not agent.registrar.complete(creder.said, sn=1): + doist.recur(deeds=agent_deeds) + + res = client.simulate_post("/credentials/query") + assert res.status_code == 200 + assert len(res.json) == 1 + assert res.json[0]["sad"]["d"] == creder.said + assert res.json[0]["status"]["s"] == "1" + + res = client.simulate_post("/credentials/query") + assert res.status_code == 200 + assert len(res.json) == 1 + assert res.json[0]["sad"]["d"] == creder.said + assert res.json[0]["status"]["s"] == "1" + finally: + doist.exit(deeds=agent_deeds) + + res = client.simulate_get(f"/registries/{registry['regk']}/{creder.said}") assert res.status_code == 200 - assert len(res.json) == 1 - assert res.json[0]["sad"]["d"] == creder.said - assert res.json[0]["status"]["s"] == "0" + assert res.json["s"] == "1" + assert res.json["et"] == "rev" - regser = eventing.revoke( - vcdig=creder.said, regk=registry["regk"], dig=regser.said, dt=dt - ) - anchor = dict(i=regser.ked["i"], s=regser.ked["s"], d=regser.said) - serder, sigers = helpers.interact( - pre=iaid, bran=isalt, pidx=0, ridx=0, dig=serder.said, sn="3", data=[anchor] - ) - body = dict(rev=regser.ked, ixn=serder.ked, sigs=sigers) - res = client.simulate_delete( - path=f"/identifiers/badname/credentials/{creder.said}", - body=json.dumps(body).encode("utf-8"), - ) - assert res.status_code == 404 - assert res.json == { - "description": "badname is not a valid reference to an identifier", - "title": "404 Not Found", - } +def test_issue_credential_replays_multisig_embeds_before_local_approval( + helpers, seeder, monkeypatch +): + """Follower B must replay A's stored `/multisig/iss` embeds before approval. - res = client.simulate_delete( - path=f"/identifiers/issuer/credentials/{regser.said}", - body=json.dumps(body).encode("utf-8"), - ) - assert res.status_code == 404 - assert res.json == { - "description": f"credential for said {regser.said} not found.", - "title": "404 Not Found", - } + This is an endpoint-order test for the follower approval path in + `CredentialCollectionEnd.on_post(...)`. - badrev = regser.ked.copy() - badrev["ri"] = "EIVtei3pGKGUw8H2Ri0h1uOevtSA6QGAq5wifbtHIaNI" - _, sad = coring.Saider.saidify(badrev) + Identity mapping: - badbody = dict(rev=sad, ixn=serder.ked, sigs=sigers) - res = client.simulate_delete( - path=f"/identifiers/issuer/credentials/{creder.said}", - body=json.dumps(badbody).encode("utf-8"), - ) - assert res.status_code == 404 - assert res.json == { - "description": "revocation against invalid registry SAID " - "EIVtei3pGKGUw8H2Ri0h1uOevtSA6QGAq5wifbtHIaNI", - "title": "404 Not Found", - } + - participant B is the local `issuer` habitat created by + `helpers.createRegistry(...)`. + - participant A is represented by the mocked `replay_multisig_embeds(...)` + invocation, which stands in for the already-stored remote EXN. + - `creder`, `iserder`, and `anc` are B's local approval payload for the + same issuance proposal. - badrev = regser.ked.copy() - badrev["i"] = "EMgdjM1qALk3jlh4P2YyLRSTcjSOjLXD3e_uYpxbdbg6" - _, sad = coring.Saider.saidify(badrev) + We assert only the ordering guarantee that matters for this bug: + KERIA must replay A's stored `anc` and `acdc` streams before B's local + interaction approval is processed. + """ + with helpers.openKeria() as (agency, agent, app, client): + idResEnd = aiding.IdentifierResourceEnd() + app.add_route("/identifiers/{name}", idResEnd) + registryEnd = credentialing.RegistryCollectionEnd(idResEnd) + app.add_route("/identifiers/{name}/registries", registryEnd) + credEnd = credentialing.CredentialCollectionEnd(idResEnd) + app.add_route("/identifiers/{name}/credentials", credEnd) + end = aiding.IdentifierCollectionEnd() + app.add_route("/identifiers", end) - badbody = dict(rev=sad, ixn=serder.ked, sigs=sigers) - res = client.simulate_delete( - path=f"/identifiers/issuer/credentials/{creder.said}", - body=json.dumps(badbody).encode("utf-8"), - ) - assert res.status_code == 400 - assert res.json == { - "description": "invalid revocation event.", - "title": "400 Bad Request", - } + seeder.seedSchema(agent.hby.db) - res = client.simulate_delete( - path=f"/identifiers/issuer/credentials/{creder.said}", - body=json.dumps(body).encode("utf-8"), - ) - assert res.status_code == 200 + doist = doing.Doist(limit=1.0, tock=0.03125, real=True) + agent_deeds = doist.enter(doers=[agent]) + try: + isalt = b"0123456789abcdef" + registry, follower_b_issuer = helpers.createRegistry( + client, agent, isalt, doist, agent_deeds + ) - while not agent.registrar.complete(creder.said, sn=1): - doist.recur(deeds=deeds) + dt = "2021-01-01T00:00:00.000000+00:00" + creder = proving.credential( + issuer=follower_b_issuer["prefix"], + schema="EFgnk_c08WmZGgv9_mpldibRuqFMTQN-rAgtD-TCOwbs", + recipient=follower_b_issuer["prefix"], + data=dict(LEI="254900DA0GOGCFVWB618", dt=dt), + source={}, + status=registry["regk"], + ) + iserder = eventing.issue(vcdig=creder.said, regk=registry["regk"], dt=dt) + anchor = dict(i=iserder.ked["i"], s=iserder.ked["s"], d=iserder.said) + anc, _ = helpers.interact( + pre=follower_b_issuer["prefix"], + bran=isalt, + pidx=0, + ridx=0, + dig=follower_b_issuer["state"]["d"], + sn="2", + data=[anchor], + ) - res = client.simulate_post("/credentials/query") - assert res.status_code == 200 - assert len(res.json) == 1 - assert res.json[0]["sad"]["d"] == creder.said - assert res.json[0]["status"]["s"] == "1" + class FakeOp: + def to_json(self): + return json.dumps(dict(name="mock-op")) - res = client.simulate_post("/credentials/query") - assert res.status_code == 200 - assert len(res.json) == 1 - assert res.json[0]["sad"]["d"] == creder.said - assert res.json[0]["status"]["s"] == "1" + calls = [] - res = client.simulate_get(f"/registries/{registry['regk']}/{creder.said}") - assert res.status_code == 200 - assert res.json["s"] == "1" - assert res.json["et"] == "rev" + # Participant A's fake replay + def fake_replay(*args, **kwargs): + calls.append(("replay", kwargs["route"], kwargs["labels"])) + + def fake_interact(*args, **kwargs): + calls.append("interact") + return FakeOp() + + monkeypatch.setattr(credentialing, "replay_multisig_embeds", fake_replay) + monkeypatch.setattr(idResEnd, "interact", fake_interact) + monkeypatch.setattr( + agent.credentialer, "validate", lambda *args, **kwargs: None + ) + monkeypatch.setattr( + agent.registrar, + "issue", + lambda regk, iser, anc_serder: calls.append("registrar.issue"), + ) + monkeypatch.setattr( + agent.credentialer, + "issue", + lambda *args, **kwargs: calls.append("credentialer.issue"), + ) + monkeypatch.setattr( + agent.monitor, + "submit", + lambda *args, **kwargs: FakeOp(), + ) + + body = dict(acdc=creder.sad, iss=iserder.ked, ixn=anc.ked) + res = client.simulate_post( + path="/identifiers/issuer/credentials", + body=json.dumps(body).encode("utf-8"), + ) + + assert res.status_code == 200 + assert calls[:2] == [ + ("replay", "/multisig/iss", ("anc", "acdc")), + "interact", + ] + finally: + doist.exit(deeds=agent_deeds) + + +def test_revoke_credential_replays_multisig_embeds_before_local_approval( + helpers, monkeypatch +): + """Follower B must replay A's stored `/multisig/rev` `anc` before approval. + + Identity mapping mirrors the issuance test above: + + - participant B is the local `issuer` habitat handled by this KERIA agent. + - participant A is represented by the mocked replay helper invocation. + - `rserder` and `anc` are B's local revocation approval payload. + + The asserted behavior is that KERIA replays A's stored anchoring event + stream before it processes B's local interaction approval. + """ + with helpers.openKeria() as (agency, agent, app, client): + idResEnd = aiding.IdentifierResourceEnd() + app.add_route("/identifiers/{name}", idResEnd) + registryEnd = credentialing.RegistryCollectionEnd(idResEnd) + app.add_route("/identifiers/{name}/registries", registryEnd) + credResDelEnd = credentialing.CredentialResourceDeleteEnd(idResEnd) + app.add_route("/identifiers/{name}/credentials/{said}", credResDelEnd) + end = aiding.IdentifierCollectionEnd() + app.add_route("/identifiers", end) + + doist = doing.Doist(limit=1.0, tock=0.03125, real=True) + agent_deeds = doist.enter(doers=[agent]) + try: + isalt = b"0123456789abcdef" + registry, follower_b_issuer = helpers.createRegistry( + client, agent, isalt, doist, agent_deeds + ) + + dt = "2021-01-01T00:00:00.000000+00:00" + creder = proving.credential( + issuer=follower_b_issuer["prefix"], + schema="EFgnk_c08WmZGgv9_mpldibRuqFMTQN-rAgtD-TCOwbs", + recipient=follower_b_issuer["prefix"], + data=dict(LEI="254900DA0GOGCFVWB618", dt=dt), + source={}, + status=registry["regk"], + ) + iserder = eventing.issue(vcdig=creder.said, regk=registry["regk"], dt=dt) + rserder = eventing.revoke( + vcdig=creder.said, regk=registry["regk"], dig=iserder.said, dt=dt + ) + anchor = dict(i=rserder.ked["i"], s=rserder.ked["s"], d=rserder.said) + anc, _ = helpers.interact( + pre=follower_b_issuer["prefix"], + bran=isalt, + pidx=0, + ridx=0, + dig=follower_b_issuer["state"]["d"], + sn="2", + data=[anchor], + ) + + class FakeOp: + def to_json(self): + return json.dumps(dict(name="mock-op")) + + calls = [] + + # Participant A modeled as a fake replay + def fake_replay(*args, **kwargs): + calls.append(("replay", kwargs["route"], kwargs["labels"])) + + def fake_interact(*args, **kwargs): + calls.append("interact") + return FakeOp() + + monkeypatch.setattr(credentialing, "replay_multisig_embeds", fake_replay) + monkeypatch.setattr(idResEnd, "interact", fake_interact) + monkeypatch.setattr( + agent.rgy.reger, "cloneCreds", lambda *args, **kwargs: [] + ) + monkeypatch.setattr( + agent.registrar, + "revoke", + lambda regk, rser, anc_ked: calls.append("registrar.revoke"), + ) + + body = dict(rev=rserder.ked, ixn=anc.ked) + res = client.simulate_delete( + path=f"/identifiers/issuer/credentials/{creder.said}", + body=json.dumps(body).encode("utf-8"), + ) + + assert res.status_code == 200 + assert calls[:2] == [("replay", "/multisig/rev", ("anc",)), "interact"] + finally: + doist.exit(deeds=agent_deeds) + + +def test_keria_registrar_issue_starts_counselor_for_multisig_hab(monkeypatch): + """Registrar.issue must start counselor tracking for multisig issuance. + + This is the post-replay side of the same mental model. Once the follower + approval path has assembled the shared anchoring event for the group, the + registrar still must start counselor tracking for the group's anchoring + event. Otherwise the TEL issuance event can remain stuck in multisig escrow. + """ + + class FakeSignifyGroupHab: + pass + + monkeypatch.setattr(credentialing, "SignifyGroupHab", FakeSignifyGroupHab) + + regser = eventing.incept( + "EHgwVwQT15OJvilVvW57HE4w0-GPs_Stj2OFoAHZSysY", + baks=[], + toad="0", + nonce=Salter().qb64, + cnfg=[TraitCodex.NoBackers], + code=coring.MtrDex.Blake3_256, + ) + iserder = eventing.issue( + vcdig=regser.said, regk=regser.pre, dt="2021-01-01T00:00:00.000000+00:00" + ) + anc = SimpleNamespace(sn=3, said=iserder.said) + + counselor_calls = [] + tmse_entries = [] + + hab = FakeSignifyGroupHab() + hab.name = "issuer-group" + hab.pre = "EHgwVwQT15OJvilVvW57HE4w0-GPs_Stj2OFoAHZSysY" + + registry = SimpleNamespace( + hab=hab, + processEvent=lambda serder: None, + ) + registrar = credentialing.Registrar( + agentHab=None, + hby=None, + rgy=SimpleNamespace( + regs={regser.pre: registry}, + reger=SimpleNamespace( + tmse=SimpleNamespace( + add=lambda keys, val: tmse_entries.append((keys, val)) + ) + ), + ), + counselor=SimpleNamespace( + start=lambda **kwargs: counselor_calls.append(kwargs) + ), + witDoer=None, + witPub=None, + verifier=None, + ) + + vcid, sn = registrar.issue(regser.pre, iserder, anc) + + assert (vcid, sn) == (iserder.ked["i"], coring.Seqner(snh=iserder.ked["s"]).sn) + assert len(counselor_calls) == 1 + assert counselor_calls[0]["ghab"] is hab + assert counselor_calls[0]["prefixer"].qb64 == hab.pre + assert counselor_calls[0]["seqner"].sn == anc.sn + assert counselor_calls[0]["saider"].qb64 == anc.said + assert len(tmse_entries) == 1 + assert tmse_entries[0][0] == ( + iserder.ked["i"], + coring.Seqner(snh=iserder.ked["s"]).qb64, + iserder.said, + ) diff --git a/tests/app/test_multisig.py b/tests/app/test_multisig.py new file mode 100644 index 00000000..3f9fb4fe --- /dev/null +++ b/tests/app/test_multisig.py @@ -0,0 +1,308 @@ +# -*- encoding: utf-8 -*- +""" +KERIA +keria.app.multisig module + +Focused tests for replaying stored multisig embedded event streams. +""" + +from types import SimpleNamespace + +from keri.core import coring, eventing as core_eventing, serdering +from keri.core.signing import Salter +from keri.kering import TraitCodex +from keri.vc import proving +from keri.vdr import eventing as veventing + +from keria.app import aiding, multisig + + +def _replay_streams(monkeypatch, *, route, embeds, labels, paths): + """Simulate follower B replaying a proposal that leader A sent earlier. + + This helper is the shared fixture for the route-specific replay tests + below. It intentionally models the exact local-after-remote ordering that + the KLI uses in JoinDoer: + + 1. participant A already sent `/multisig/*` and that EXN is stored in mux + storage, + 2. participant B later approves the same proposal locally, + 3. KERIA must replay A's stored embedded event stream before it can finish + B's approval. + + Identity mapping used throughout this helper: + + - `StoredRemoteProposalMux` is participant A's already-stored proposal. + - `fake_agent` is participant B's KERIA agent at approval time. + - `hab.mhab.pre` is participant B's member AID, which is why the replay + helper skips EXNs authored by that same prefix. + + The returned `parsed_message_streams` list is therefore the exact stream + KERIA replays for participant B from participant A's stored EXN. + """ + + class FakeSignifyGroupHab: + pass + + monkeypatch.setattr(multisig, "SignifyGroupHab", FakeSignifyGroupHab) + + embed_ked = dict(embeds) + embed_ked["d"] = "" + _, embed_ked = coring.Saider.saidify(sad=embed_ked, label=coring.Saids.d) + proposal_esaid = embed_ked["d"] + + parsed_message_streams = [] + + # Simple parser mock for testing + class RecordingParser: + def parseOne(self, ims): + parsed_message_streams.append(bytes(ims)) + + # Multiplexor mock for simple testing + class StoredRemoteProposalMux: + def get(self, esaid): + assert esaid == proposal_esaid + return [ + dict( + # Participant A's previously received multisig EXN. The + # route and attachments here are the canonical remote + # proposal participant B must replay before B continues + # with local approval. + exn={"r": route, "i": "remote-member-a-pre"}, + paths=paths, + ) + ] + + # This fake agent is participant B's KERIA process when B later approves + # the same proposal through a local HTTP route such as `/registries` or + # `/credentials`. + fake_agent = SimpleNamespace( + mux=StoredRemoteProposalMux(), + hby=SimpleNamespace(psr=RecordingParser()), + ) + hab = FakeSignifyGroupHab() + hab.mhab = SimpleNamespace(pre="local-member-b-pre") + + replays = multisig.replay_multisig_embeds( + fake_agent, + hab, + route=route, + embeds=embeds, + labels=labels, + ) + + return replays, parsed_message_streams + + +def test_replay_multisig_embeds_replays_stored_remote_vcp_anc_stream( + helpers, monkeypatch +): + """Follower B must replay leader A's stored `anc` stream for `/multisig/vcp`. + + The concrete fixture objects are: + + - `regser` and `anc`: the proposal participant B is about to approve. + - `paths["anc"]`: participant A's previously stored signature attachments. + - `_replay_streams(...)`: participant B's KERIA view at approval time. + + The assertion proves that B replays A's exact anchoring event stream, + meaning the raw `ixn` bytes plus A's attachment group. + """ + with helpers.openKeria() as (_, __, app, client): + app.add_route("/identifiers", aiding.IdentifierCollectionEnd()) + + salt = b"0123456789abcdef" + op = helpers.createAid(client, "test", salt) + aid = op["response"] + follower_member_b_pre = aid["i"] + + regser = veventing.incept( + follower_member_b_pre, + baks=[], + toad="0", + nonce=Salter().qb64, + cnfg=[TraitCodex.NoBackers], + code=coring.MtrDex.Blake3_256, + ) + anchor = dict(i=regser.ked["i"], s=regser.ked["s"], d=regser.said) + anc, _ = helpers.interact( + pre=follower_member_b_pre, + bran=salt, + pidx=0, + ridx=0, + dig=aid["d"], + sn="1", + data=[anchor], + ) + + replays, parsed_streams = _replay_streams( + monkeypatch, + route="/multisig/vcp", + embeds=dict(vcp=regser.ked, anc=anc.ked), + labels=("anc",), + paths={"anc": "FAKE-REMOTE-ANC-ATC"}, + ) + + expected = bytearray(serdering.SerderKERI(sad=anc.ked).raw) + expected.extend(b"FAKE-REMOTE-ANC-ATC") + + assert replays == 1 + assert parsed_streams == [bytes(expected)] + + +def test_replay_multisig_embeds_replays_stored_remote_rpy_stream(helpers, monkeypatch): + """Follower B must replay leader A's stored `rpy` stream for `/multisig/rpy`. + + The locally built `rserder` stands in for the exact reply participant B is + about to endorse. The stored `paths["rpy"]` attachment group represents the + signature material that already came from participant A's earlier EXN. + """ + with helpers.openKeria() as (_, agent, app, client): + app.add_route("/identifiers", aiding.IdentifierCollectionEnd()) + + salt = b"0123456789abcdef" + op = helpers.createAid(client, "user1", salt) + aid = op["response"] + rserder = helpers.endrole(aid["i"], agent.agentHab.pre) + + replays, parsed_streams = _replay_streams( + monkeypatch, + route="/multisig/rpy", + embeds=dict(rpy=rserder.ked), + labels=("rpy",), + paths={"rpy": "FAKE-REMOTE-RPY-ATC"}, + ) + + expected = bytearray(serdering.SerderKERI(sad=rserder.ked).raw) + expected.extend(b"FAKE-REMOTE-RPY-ATC") + + assert replays == 1 + assert parsed_streams == [bytes(expected)] + + +def test_replay_multisig_embeds_replays_stored_remote_issue_streams( + helpers, monkeypatch +): + """Follower B must replay A's `anc` and `acdc` streams for `/multisig/iss`. + + This is the most subtle replay case: + + - `anc` is the anchoring KEL event that both participants must mirror. + - `acdc` is the credential payload stream participant A already attached. + - participant B replays both before B's local issuance approval continues. + """ + with helpers.openKeria() as (_, __, app, client): + app.add_route("/identifiers", aiding.IdentifierCollectionEnd()) + + salt = b"0123456789abcdef" + op = helpers.createAid(client, "issuer", salt) + aid = op["response"] + follower_member_b_pre = aid["i"] + + regser = veventing.incept( + follower_member_b_pre, + baks=[], + toad="0", + nonce=Salter().qb64, + cnfg=[TraitCodex.NoBackers], + code=coring.MtrDex.Blake3_256, + ) + creder = proving.credential( + issuer=follower_member_b_pre, + schema="EFgnk_c08WmZGgv9_mpldibRuqFMTQN-rAgtD-TCOwbs", + recipient=follower_member_b_pre, + data=dict( + LEI="254900DA0GOGCFVWB618", dt="2021-01-01T00:00:00.000000+00:00" + ), + source={}, + status=regser.pre, + ) + iserder = veventing.issue( + vcdig=creder.said, + regk=regser.pre, + dt="2021-01-01T00:00:00.000000+00:00", + ) + anchor = dict(i=iserder.ked["i"], s=iserder.ked["s"], d=iserder.said) + anc, _ = helpers.interact( + pre=follower_member_b_pre, + bran=salt, + pidx=0, + ridx=0, + dig=aid["d"], + sn="1", + data=[anchor], + ) + + replays, parsed_streams = _replay_streams( + monkeypatch, + route="/multisig/iss", + embeds=dict(acdc=creder.sad, iss=iserder.ked, anc=anc.ked), + labels=("anc", "acdc"), + paths={ + "anc": "FAKE-REMOTE-ANC-ATC", + "acdc": "FAKE-REMOTE-ACDC-ATC", + }, + ) + + expected_anc = bytearray(serdering.SerderKERI(sad=anc.ked).raw) + expected_anc.extend(b"FAKE-REMOTE-ANC-ATC") + expected_acdc = bytearray(serdering.SerderACDC(sad=creder.sad).raw) + expected_acdc.extend(b"FAKE-REMOTE-ACDC-ATC") + + assert replays == 1 + assert parsed_streams == [bytes(expected_anc), bytes(expected_acdc)] + + +def test_replay_multisig_embeds_replays_stored_remote_rev_anc_stream( + helpers, monkeypatch +): + """Follower B must replay A's stored anchoring event for `/multisig/rev`. + + Revocation only needs the previously stored `anc` stream replayed before + participant B processes the local revocation approval. + """ + with helpers.openKeria() as (_, __, app, client): + app.add_route("/identifiers", aiding.IdentifierCollectionEnd()) + + salt = b"0123456789abcdef" + op = helpers.createAid(client, "issuer", salt) + aid = op["response"] + follower_member_b_pre = aid["i"] + + regser = veventing.incept( + follower_member_b_pre, + baks=[], + toad="0", + nonce=Salter().qb64, + cnfg=[TraitCodex.NoBackers], + code=coring.MtrDex.Blake3_256, + ) + issue_serder = veventing.issue( + vcdig=regser.said, + regk=regser.pre, + dt="2021-01-01T00:00:00.000000+00:00", + ) + rserder = veventing.revoke( + vcdig=regser.said, + regk=regser.pre, + dig=issue_serder.said, + dt="2021-01-01T00:00:00.000000+00:00", + ) + anchor = dict(i=rserder.ked["i"], s=rserder.ked["s"], d=rserder.said) + anc = core_eventing.interact( + pre=follower_member_b_pre, dig=aid["d"], sn=1, data=[anchor] + ) + + replays, parsed_streams = _replay_streams( + monkeypatch, + route="/multisig/rev", + embeds=dict(rev=rserder.ked, anc=anc.ked), + labels=("anc",), + paths={"anc": "FAKE-REMOTE-ANC-ATC"}, + ) + + expected = bytearray(serdering.SerderKERI(sad=anc.ked).raw) + expected.extend(b"FAKE-REMOTE-ANC-ATC") + + assert replays == 1 + assert parsed_streams == [bytes(expected)]