diff --git a/setup.py b/setup.py index 04e100e0e..4115318f1 100644 --- a/setup.py +++ b/setup.py @@ -83,7 +83,7 @@ 'cbor2>=5.6.2', 'multidict>=6.0.5', 'ordered-set>=4.1.0', - 'hio>=0.6.14', + 'hio==0.6.14', 'multicommand>=1.0.0', 'jsonschema>=4.21.1', 'falcon>=3.1.3', diff --git a/src/keri/app/agenting.py b/src/keri/app/agenting.py index 1f340b226..f9061a3d3 100644 --- a/src/keri/app/agenting.py +++ b/src/keri/app/agenting.py @@ -297,16 +297,23 @@ def __init__(self, hby, msgs=None, cues=None, force=False, auths=None, **kwa): def receiptDo(self, tymth=None, tock=0.0, **kwa): """ - Returns doifiable Doist compatible generator method (doer dog) - - Usage: - add result of doify on this method to doers list - + Generator that asynchronously processes witness receipt requests from self.msgs queue. + + For each request in msgs (dict with 'pre' and optional 'sn'): + 1. Creates HTTPMessenger or TCPMessenger for each witness + 2. Sends delegator KEL (if delegated) to each witness for context + 3. Sends full KEL to newly added witnesses (icp/dip or rotation adds) + 4. Sends the target event to each witness + 5. Waits for all witness receipts to arrive in hab.db (via MailboxDirector) + 6. Propagates all collected receipts to each witness so they know about each other + 7. Pushes the original request to self.cues to signal completion + + The receipts themselves arrive asynchronously via the MailboxDirector which + polls the witness mailbox and parses incoming receipts into hab.db. + Parameters: - tymth is injected function wrapper closure returned by .tymen() of - Tymist instance. Calling tymth() returns associated Tymist .tyme. - tock is injected initial tock value - + tymth: injected function wrapper closure from Tymist for timing + tock: injected initial tock value for yielding """ self.wind(tymth) self.tock = tock @@ -358,13 +365,13 @@ def receiptDo(self, tymth=None, tock=0.0, **kwa): witer.msgs.append(bytearray(msg)) # make a copy _ = (yield self.tock) - while True: + while True: # wait for all receipts to arrive wigs = hab.db.getWigs(dgkey) if len(wigs) == len(wits): break _ = yield self.tock - # If we started with all our recipts, exit unless told to force resubmit of all receipts + # If we started with all our receipts, exit unless told to force resubmit of all receipts if completed and not self.force: self.cues.push(evt) continue diff --git a/src/keri/app/cli/commands/delegate/confirm.py b/src/keri/app/cli/commands/delegate/confirm.py index 25eb27764..5d640234d 100644 --- a/src/keri/app/cli/commands/delegate/confirm.py +++ b/src/keri/app/cli/commands/delegate/confirm.py @@ -121,12 +121,10 @@ def confirmDo(self, tymth, tock=0.0): if ilk in (coring.Ilks.dip,): typ = "inception" delpre = eserder.sad["di"] - elif ilk in (coring.Ilks.drt,): typ = "rotation" dkever = self.hby.kevers[eserder.pre] delpre = dkever.delpre - else: continue @@ -170,11 +168,11 @@ def confirmDo(self, tymth, tock=0.0): saider = self.hby.db.cgms.get(keys=(prefixer.qb64, sner.qb64)) if saider is not None: break - yield self.tock - print(f"Delegate {eserder.pre} {typ} event committed.") + print(f"Delegate {typ} event {eserder.pre} committed.") + self.hby.db.delegables.rem(keys=(pre, sn), val=edig) self.remove(self.toRemove) return True @@ -211,27 +209,23 @@ def confirmDo(self, tymth, tock=0.0): while not witDoer.cues: _ = yield self.tock - print(f'Delegagtor Prefix {hab.pre}') - print(f'\tDelegate {eserder.pre} {typ} Anchored at Seq. No. {hab.kever.sner.num}') + print(f'Delegagtor Prefix {hab.pre}') + print(f'\tDelegate {typ} event {eserder.pre} Anchored at Seq. No. {hab.kever.sner.num}') # wait for confirmation of fully commited event if eserder.pre in self.hby.kevers: self.witq.query(src=hab.pre, pre=eserder.pre, sn=eserder.sn) - while eserder.sn < self.hby.kevers[eserder.pre].sn: yield self.tock - - print(f"Delegate {eserder.pre} {typ} event committed.") else: # It should be an inception event then... wits = [werfer.qb64 for werfer in eserder.berfers] self.witq.query(src=hab.pre, pre=eserder.pre, sn=eserder.sn, wits=wits) - while eserder.pre not in self.hby.kevers: yield self.tock - print(f"Delegate {eserder.pre} {typ} event committed.") + print(f"Delegate {typ} event {eserder.pre} committed.") - self.hby.db.delegables.rem(keys=(pre, sn)) + self.hby.db.delegables.rem(keys=(pre, sn), val=edig) self.remove(self.toRemove) return True diff --git a/src/keri/app/cli/commands/multisig/join.py b/src/keri/app/cli/commands/multisig/join.py index 829b8a393..d58d78652 100644 --- a/src/keri/app/cli/commands/multisig/join.py +++ b/src/keri/app/cli/commands/multisig/join.py @@ -187,7 +187,7 @@ def incept(self, attrs): inits["toad"] = oicp.ked["bt"] inits["wits"] = oicp.ked["b"] - inits["delpre"] = oicp.ked["di"] if "di" in ked else None + inits["delpre"] = oicp.ked["di"] if "di" in oicp.ked else None print() print("Group Multisig Inception proposed:") diff --git a/src/keri/app/habbing.py b/src/keri/app/habbing.py index 7d746434d..2948541c4 100644 --- a/src/keri/app/habbing.py +++ b/src/keri/app/habbing.py @@ -102,7 +102,7 @@ def openHab(name="test", base="", salt=None, temp=True, cf=None, **kwa): with openHby(name=name, base=base, salt=salt, temp=temp, cf=cf) as hby: if (hab := hby.habByName(name)) is None: - hab = hby.makeHab(name=name, icount=1, isith='1', ncount=1, nsith='1', **kwa) + hab = hby.makeHab(name=name, icount=1, isith='1', ncount=1, nsith='1', cf=cf, **kwa) yield hby, hab diff --git a/src/keri/core/eventing.py b/src/keri/core/eventing.py index 93c88c432..3fad7d033 100644 --- a/src/keri/core/eventing.py +++ b/src/keri/core/eventing.py @@ -1682,27 +1682,58 @@ def locallyOwned(self, pre: str | None = None): def locallyDelegated(self, pre: str): - """Returns True if pre is in .prefixes and not in .groups - False otherwise. Use when pre is a delegator for some event and - want to confirm that pre is also locallyOwned thereby making the - associated event locallyDelegated. + """Returns True if pre w is in .prefixes which includes group AIDs in + self.groups which have a local member AID. + + Which means it is either locally controlled single sig or a multi-sig + group with a locally controlled member. + False otherwise. + + Use when pre is a delegator, i.e. the delpre from some delegated event and + want to confirm that pre is also locally controller as either the single + sig AID or the group multisig AID of a locally controlled member of the group. Indicates that provided identifier prefix is controlled by a local - controller from .prefixes but is not a group with local member. - i.e pre is a locally owned (controlled) AID (identifier prefix) + controller from .prefixes is a group prefix that is controlled by a local + member of that group. + Because delpre may be None, changes the default to "" instead of self.prefixer.pre because self.prefixer.pre is delegate not delegator of self. Unaccepted dip events do not have self.delpre set yet. Returns: - (bool): True if pre is local hab but not group hab + (bool): True if pre is local hab or group hab that has a local member When pre="" empty or None then returns False Parameters: pre (str): qb64 identifier prefix if any. + + + ToDo: this code does not account for stale group members as delegators. + i.e. a stale group membed is a member AID for a group AID in .groups + for which the member AID was a signing (smids) or rotating (rmids) member + in the past but is no longer. For delegation approval there must be + a local member for the delegator group AID that is a current signing member + i,e. in .smids for the group hab. + + The current logic allows an event to be escrowed for later approval + but whose delpre (delegator) is a group with a stale local member + That later approval must detect and properly handle the staleness. + + Alternatively the logic could be changed to short circut that later + work by checking here for staleness. For example: + delpre.mhab.pre in delpre's hab.smids (not stale ) + + + if pre in self.groups: # local group delegator + habord = self.db.habs.get(keys=(pre,)) + return habord.mid in habord.smids # True not stale, False stale + + return pre in self.prefixes # otherwise local non-group delegator + """ pre = pre if pre is not None else "" - return self.locallyOwned(pre=pre) + return pre in self.prefixes def locallyWitnessed(self, *, wits: list[str]=None, serder: (str)=None): @@ -2388,8 +2419,7 @@ def valSigsWigsDel(self, serder, sigers, verfers, tholder, # seal in this case can't be malicious since sourced locally. # Doesn't get to here until fully signed and witnessed. - if self.locallyDelegated(delpre) and not self.locallyOwned(): # local delegator - # must be local if locallyDelegated or caught above as misfit + if serder.ilk in (Ilks.dip, Ilks.drt) and self.locallyDelegated(delpre) and not self.locallyOwned(): # local delegator of delegated event if delseqner is None or delsaider is None: # missing delegation seal # so escrow delegable. So local delegator can approve OOB. # and create delegator event with valid event seal of this @@ -5408,6 +5438,7 @@ def processEscrows(self): self.processEscrowPartialWigs() self.processEscrowPartialSigs() self.processEscrowDuplicitous() + self.processEscrowDelegables() self.processQueryNotFound() except Exception as ex: # log diagnostics errors etc @@ -6398,17 +6429,9 @@ def processEscrowDelegables(self): wigers = [Siger(qb64b=bytes(wig)) for wig in wigs] # get delgate seal - couple = self.db.getAes(dgkey) - if couple is not None: # Only try to parse the event if we have the del seal - raw = bytearray(couple) - seqner = coring.Seqner(qb64b=raw, strip=True) - saider = coring.Saider(qb64b=raw) - - # process event - self.processEvent(serder=eserder, sigers=sigers, wigers=wigers, delseqner=seqner, - delsaider=saider, local=esr.local) - else: - raise MissingDelegableApprovalError("No delegation seal found for event.") + seqner, saider = self._getDelegationSeal(eserder=eserder, dgkey=dgkey) + self.processEvent(serder=eserder, sigers=sigers, wigers=wigers, delseqner=seqner, + delsaider=saider, local=esr.local) except MissingDelegableApprovalError as ex: # still waiting on missing delegation approval @@ -6432,6 +6455,58 @@ def processEscrowDelegables(self): "event=%s", eserder.said) logger.debug("event=\n%s\n", eserder.pretty()) + def _getDelegationSeal(self, eserder: serdering.SerderKERI, dgkey: bytes) -> tuple[ + coring.Seqner, coring.Saider]: + """ + Get sequence number (delseqner) and event digest (delsaider) + for delegated inception (dip) or rotation (drt) event from AES seal database or KEL state. + + Parameters: + eserder: SerderKERI instance of the delegated event + dgkey: bytes of the digest key of the delegated event + Returns: + (Seqner, Saider): sequence number and event digest + Raises: + MissingDelegableApprovalError: if the delegation seal is not found + """ + # get delgate seal + couple = self.db.getAes(dgkey) + if couple is not None: # Only try to parse the event if we have the del seal + raw = bytearray(couple) + seqner = coring.Seqner(qb64b=raw, strip=True) + saider = coring.Saider(qb64b=raw) + return seqner, saider + else: + # Check KEL for seal (like in valSigsWigsDel) + # This handles the case where delegation was approved + # and the seal is in the delegator's KEL + if eserder.ilk in (Ilks.dip, Ilks.drt): + # Get delpre (delegator prefix) from dip or drt + if eserder.ilk == Ilks.dip: + delpre = eserder.delpre # delegator from dip event + if not delpre: + raise MissingDelegableApprovalError( + f"Empty or missing delegator for delegated inception event = {eserder.said}.") + else: # For drt, delpre is in kever state + # If we have the kever, use it; otherwise we can't process + if eserder.pre in self.kevers: + delpre = self.kevers[eserder.pre].delpre + else: + raise MissingDelegableApprovalError( + f"No kever found for delegated rotation event = {eserder.said}.") + + # Look up seal in delegator's KEL + seal = dict(i=eserder.pre, s=eserder.snh, d=eserder.said) + dserder = self.db.fetchLastSealingEventByEventSeal(pre=delpre, seal=seal) + if dserder is not None: # found seal - use it + seqner = coring.Seqner(sn=dserder.sn) + saider = coring.Saider(qb64=dserder.said) + return seqner, saider + else: + raise MissingDelegableApprovalError("No delegation seal found for event.") + else: + raise MissingDelegableApprovalError("No delegation seal found for event.") + def processQueryNotFound(self): """ Process qry events escrowed by Kevery for KELs that have not yet met the criteria of the query. diff --git a/tests/app/app_helpers.py b/tests/app/app_helpers.py new file mode 100644 index 000000000..1de43f331 --- /dev/null +++ b/tests/app/app_helpers.py @@ -0,0 +1,1096 @@ +""" +tests.app.app_helpers module + +Helpers for test setup including context managers for witnesses, controllers, +and orchestration Doers for multisig and delegation workflows. +""" +import json +from collections import deque +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import List, Generator, Tuple + +from hio.base import Doer, doing, Doist +from hio.help import decking + +from keri import kering +from keri.app import habbing, delegating, grouping, oobiing +from keri.app.agenting import WitnessReceiptor, Receiptor, WitnessInquisitor +from keri.app.configing import Configer +from keri.app.delegating import Anchorer +from keri.app.forwarding import Poster +from keri.app.habbing import openHab, HaberyDoer, Habery, Hab, openHby, GroupHab +from keri.app.indirecting import MailboxDirector, setupWitness +from keri.app.notifying import Notifier +from keri.core import Salter, coring, serdering, indexing +from keri.db import basing, dbing +from keri.help import helping +from keri.peer import exchanging +from keri.peer.exchanging import Exchanger + + +# ============================================================================= +# Data Classes for Structured Returns +# ============================================================================= + +@dataclass +class EscrowDoer(doing.Doer): + """ + Doer that processes escrows for both Habery's Kevery and Counselor. + This Doer is just a testing helper to speed up event processing in tests. + + This fills a gap in the standard controller setup where: + - MailboxDirector.escrowDo processes mbx.kvy escrows (a separate Kevery for remote events) + - Counselor.escrowDo processes counselor escrows but with yield 0.5 delay, not great for tests that should run as fast as possible. + - Nothing processes hby.kvy escrows (the Habery's Kevery for local events) + + This doer runs both processEscrows calls on every recur for faster test execution. + """ + + def __init__(self, hby: Habery, counselor: grouping.Counselor = None, **kwa): + super(EscrowDoer, self).__init__(**kwa) + self.hby = hby + self.counselor = counselor + + def recur(self, tyme): + """Process escrows on every recur call for responsive tests.""" + self.hby.kvy.processEscrows() + if self.counselor is not None: + self.counselor.processEscrows() + return False # Keep running + + +@dataclass +class ControllerContext: + """Structured context for a KERI controller with all its components.""" + hby: Habery + doers: List[Doer] + hbyDoer: HaberyDoer + anchorer: Anchorer + postman: Poster + exc: Exchanger + notifier: Notifier + mbx: MailboxDirector + witReceiptor: WitnessReceiptor + receiptor: Receiptor + witq: WitnessInquisitor = None + counselor: grouping.Counselor = None + + +@dataclass +class WitnessContext: + """Structured context for a KERI witness.""" + hby: Habery + hab: Hab + doers: List[Doer] + oobi: str + pre: str = field(init=False) + + def __post_init__(self): + self.pre = self.hab.pre + + +# ============================================================================= +# Context Managers +# ============================================================================= + +@contextmanager +def openWit(name: str = 'wan', tcpPort: int = 6632, httpPort: int = 6642, + salt: bytes = b'abcdefg0123456789') -> Generator[WitnessContext, None, None]: + """ + Context manager for a KERI witness along with the Doers needed to run it. + Expects the Doers to be run by the caller. + + Returns a WitnessContext with (Habery, Hab, witness Doers, witness controller OOBI URL) + """ + saltQb64 = Salter(raw=salt).qb64 + # Witness config - use temp=True to avoid filesystem permission issues in tests + witCfg = f"""{{ + "dt": "2025-12-11T11:02:30.302010-07:00", + "{name}": {{ + "dt": "2025-12-11T11:02:30.302010-07:00", + "curls": ["tcp://127.0.0.1:{tcpPort}/", "http://127.0.0.1:{httpPort}/"]}}}}""" + cf = Configer(name=name, temp=True, reopen=True, clear=False) + cf.put(json.loads(witCfg)) + with ( + openHab(salt=bytes(saltQb64, 'utf-8'), name=name, transferable=False, temp=True, cf=cf) as (hby, hab) + ): + oobi = f'http://127.0.0.1:{httpPort}/oobi/{hab.pre}/controller?name={name}&tag=witness' + hbyDoer = HaberyDoer(habery=hby) + doers: List[Doer] = [hbyDoer] + doers.extend(setupWitness(alias=name, hby=hby, tcpPort=tcpPort, httpPort=httpPort)) + yield WitnessContext(hby=hby, hab=hab, doers=doers, oobi=oobi) + + +@contextmanager +def openCtrlWited(name: str = 'aceCtlrKS', + salt: bytes = b'aaaaaaa0123456789') -> Generator[ControllerContext, None, None]: + """ + Context manager for setting up a KERI controller that uses a witness as its mailbox and witness. + Sets up the Doers needed to run a controller including both single sig and multi-sig handlers. + Relies on an outer context manager or caller to perform OOBI resolution and inception of the controller AID. + + Expects the Doers to be run by the caller. + + Returns a ControllerContext with all components accessible. + """ + # Note: Avoid puting iurls in config - that causes auto-resolution during init + # which hangs if the witness isn't running yet. Resolve OOBIs manually instead + # unless you make sure the witness context is both created and running before + # creating this controller. + ctlrCfg = f"""{{"dt": "2025-12-11T11:02:30.302010-07:00"}}""" + cf = Configer(name=name, temp=True, reopen=True, clear=False) + cf.put(json.loads(ctlrCfg)) + # Convert raw salt bytes to qb64 format expected by openHby + saltQb64 = Salter(raw=salt).qb64 + with openHby(salt=saltQb64, name=name, temp=True, cf=cf) as hby: + hbyDoer = habbing.HaberyDoer(habery=hby) + anchorer = Anchorer(hby=hby, proxy=None) + postman = Poster(hby=hby) + exc = Exchanger(hby=hby, handlers=[]) + notifier = Notifier(hby=hby) + delegating.loadHandlers(hby=hby, exc=exc, notifier=notifier) + grouping.loadHandlers(exc=exc, mux=grouping.Multiplexor(hby=hby, notifier=notifier)) + mbx = MailboxDirector(hby=hby, exc=exc, topics=['/receipt', '/replay', '/reply', '/delegate', '/multisig']) + witReceiptor = WitnessReceiptor(hby=hby) + receiptor = Receiptor(hby=hby) + witq = WitnessInquisitor(hby=hby) + counselor = grouping.Counselor(hby=hby) + escrowDoer = EscrowDoer(hby=hby, counselor=counselor) + doers = [hbyDoer, anchorer, postman, mbx, witReceiptor, receiptor, witq, counselor, escrowDoer] + yield ControllerContext( + hby=hby, + doers=doers, + hbyDoer=hbyDoer, + anchorer=anchorer, + postman=postman, + exc=exc, + notifier=notifier, + mbx=mbx, + witReceiptor=witReceiptor, + receiptor=receiptor, + witq=witq, + counselor=counselor, + ) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +class HabHelpers: + """Static helpers for Hab/Habery operations.""" + + @staticmethod + def generateOobi(hby: Habery, alias: str, role: str = kering.Roles.witness) -> str: + """Generate an OOBI URL for the given Hab.""" + hab = hby.habByName(name=alias) + if hab is None: + raise kering.ConfigurationError(f'Hab with alias {alias} not found in Habery.') + + oobi = '' + if role in (kering.Roles.witness,): + if not hab.kever.wits: + raise kering.ConfigurationError(f'{alias} identifier {hab.pre} does not have any witnesses.') + for wit in hab.kever.wits: + urls = hab.fetchUrls(eid=wit, scheme=kering.Schemes.http) or hab.fetchUrls( + eid=wit, scheme=kering.Schemes.https + ) + if not urls: + raise kering.ConfigurationError(f'unable to query witness {wit}, no http endpoint') + url = urls[kering.Schemes.https] if kering.Schemes.https in urls else urls[kering.Schemes.http] + oobi = f'{url.rstrip("/")}/oobi/{hab.pre}/witness' + elif role in (kering.Roles.controller,): + urls = hab.fetchUrls(eid=hab.pre, scheme=kering.Schemes.http) or hab.fetchUrls( + eid=hab.pre, scheme=kering.Schemes.https + ) + if not urls: + raise kering.ConfigurationError(f'{alias} identifier {hab.pre} does not have any controller endpoints') + url = urls[kering.Schemes.https] if kering.Schemes.https in urls else urls[kering.Schemes.http] + oobi = f'{url.rstrip("/")}/oobi/{hab.pre}/controller' + + if oobi: + return oobi + else: + raise kering.ConfigurationError(f'Unable to generate OOBI for {alias} identifier {hab.pre} with role {role}') + + @staticmethod + def resolveOobi(doist: Doist, deeds: deque, hby: Habery, oobi: str, alias: str = None): + """Resolve an OOBI for a given Habery using the provided Doist and deeds.""" + obr = basing.OobiRecord(date=helping.nowIso8601()) + if alias is not None: + obr.oobialias = alias + hby.db.oobis.put(keys=(oobi,), val=obr) + + oobiery = oobiing.Oobiery(hby=hby) + authn = oobiing.Authenticator(hby=hby) + oobiery_deeds = doist.enter(doers=oobiery.doers + authn.doers) + while not oobiery.hby.db.roobi.get(keys=(oobi,)): + # Note: EscrowDoer in controller context handles processEscrows for controller deeds + # but oobiery_deeds are separate so escrows for those may still need processing + doist.recur(deeds=decking.Deck(list(deeds) + list(oobiery_deeds))) + + @staticmethod + def hasDelegables(db: basing.Baser) -> List[Tuple[str, int, bytes]]: + """Check if there are any delegable events in escrow.""" + dlgs = [] + for (pre, sn), edig in db.delegables.getItemIter(): + dlgs.append((pre, sn, edig)) + return dlgs + + @staticmethod + def collectWitnessReceipts(doist: Doist, deeds: deque, wit_receiptor, pre: str, sn: int = None): + """ + Collect witness receipts for an event. + + This queues a request for the WitnessReceiptor to send the event to all + witnesses and collect their receipts. The actual receipts arrive asynchronously + via the MailboxDirector which polls the witness mailbox. + + WitnessReceiptor.cues must be cleared after receipts are collected to ensure a clean + start condition for the next event. + + Parameters: + doist: The Doist running the event loop + deeds: The deeds to recur with (should include wit_receiptor's doers) + wit_receiptor: The WitnessReceiptor instance (from controller context) + pre: The AID prefix of the identifier to collect receipts for + sn: Optional sequence number of event (defaults to latest if not provided) + """ + msg = dict(pre=pre) + if sn is not None: + msg['sn'] = sn + wit_receiptor.msgs.append(msg) + while not wit_receiptor.cues: + doist.recur(deeds=deeds) + wit_receiptor.cues.clear() + + @staticmethod + def delegationSeal(delegateAid: str, delegateSnh: str, delegateEvtSaid: str): + """Returns a delegation seal a delegator can use to approve a delegated inception or rotation event.""" + return dict(i=delegateAid, s=delegateSnh, d=delegateEvtSaid) + + @staticmethod + def clearSentCue(postman: Poster, said: str): + """ + Remove cue(s) from Poster.cues that match the given SAID. + + This is more precise than postman.cues.clear() because it only removes + cues for the specific message, leaving other pending send confirmations intact. + + Parameters: + postman: The Poster instance + said: The SAID of the message to clear from cues + """ + # Build new list without matching cues, then replace contents + remaining = [cue for cue in postman.cues if cue.get("said") != said] + postman.cues.clear() + for cue in remaining: + postman.cues.append(cue) + + +# ============================================================================= +# Orchestration Doers for Multisig +# ============================================================================= + +class MultisigInceptLeader(doing.DoDoer): + """ + Similar to `kli multisig incept`. + Orchestrates multisig inception from the leader's perspective. + + The leader: + 1. Creates the GroupHab with makeGroupHab + 2. Sends /multisig/icp EXN notification to all followers + 3. Starts Counselor to collect signatures + 4. Waits for cgms (confirmed group multisig) + + Counselor completes only when all followers have + + Parameters: + hby: The Habery for this participant + mhab: The member Hab (single-sig AID) for this participant + smids: List of all signing member AIDs (including self) + rmids: List of all rotation member AIDs (including self) + group: Name for the new group AID + isith: Signing threshold + nsith: Next (rotation) threshold + toad: Witness threshold + wits: List of witness prefixes + delpre: Delegator prefix (if this is a delegated multisig) + postman: Poster for sending messages + counselor: Counselor for coordinating multisig + witReceiptor: WitnessReceiptor for getting receipts + """ + + def __init__(self, hby: Habery, mhab: Hab, smids: List[str], rmids: List[str], + group: str, isith: str, nsith: str, toad: int, wits: List[str], + postman: Poster, counselor: grouping.Counselor, witReceiptor: WitnessReceiptor, + delpre: str = None, **kwa): + self.hby = hby + self.mhab = mhab + self.smids = smids + self.rmids = rmids + self.group = group + self.isith = isith + self.nsith = nsith + self.toad = toad + self.wits = wits + self.delpre = delpre + self.postman = postman + self.counselor = counselor + self.witReceiptor = witReceiptor + self.ghab: GroupHab = None + self.cues = decking.Deck() + self.done = False + self.pending_sends = [] # Track SAIDs of messages waiting for delivery confirmation + self.counselor_started = False + + # Note: postman and counselor are NOT included here because they're already + # running via the controller context's doers (all_deeds). The CLI's + # GroupMultisigIncept creates its own instances, but we reuse the existing ones. + super(MultisigInceptLeader, self).__init__(doers=[], **kwa) + + def recur(self, tyme, deeds=None): + """Main orchestration loop for leading a multisig inception.""" + super(MultisigInceptLeader, self).recur(tyme, deeds=deeds) + + # Step 1: Create GroupHab and notify followers + if self.ghab is None: + self._createGroupHabAndNotifyFollowers() + return False + + # Step 2: Wait for sends to complete before starting Counselor + if not self._checkPendingSendsComplete(): + return False + + # Step 3: Start the Counselor (once, after sends complete) + if not self.counselor_started: + self._startCounselor() + return False + + # Step 4: Wait for Counselor to complete (cgms) + if self._isCounselorComplete(): + self.done = True + return True + + return False + + def _createGroupHabAndNotifyFollowers(self): + """Create the GroupHab and send /multisig/icp EXN to all followers.""" + inits = dict( + isith=self.isith, + nsith=self.nsith, + toad=self.toad, + wits=self.wits, + delpre=self.delpre, + ) + self.ghab = self.hby.makeGroupHab( + group=self.group, + mhab=self.mhab, + smids=self.smids, + rmids=self.rmids, + **inits + ) + + # Create and send the inception EXN to followers + icp = self.ghab.makeOwnInception(allowPartiallySigned=True) + exn, ims = grouping.multisigInceptExn( + self.mhab, + smids=self.smids, + rmids=self.rmids, + icp=icp + ) + + self._sendToOtherMembers(exn, ims) + self.pending_sends.append(exn.said) + + def _sendToOtherMembers(self, exn: serdering.SerderKERI, attachment: bytes): + """Send an EXN message to all other multisig members.""" + others = [m for m in self.smids if m != self.mhab.pre] + for recpt in others: + self.postman.send( + src=self.mhab.pre, + dest=recpt, + topic="multisig", + serder=exn, + attachment=attachment + ) + + def _checkPendingSendsComplete(self) -> bool: + """Check if all pending EXN sends have been delivered.""" + if not self.pending_sends: + return True + + for said in list(self.pending_sends): + if self.postman.sent(said=said): + self.pending_sends.remove(said) + HabHelpers.clearSentCue(self.postman, said) + + return not self.pending_sends + + def _startCounselor(self): + """Start the Counselor to coordinate signature collection.""" + prefixer = coring.Prefixer(qb64=self.ghab.pre) + seqner = coring.Seqner(sn=0) + saider = coring.Saider(qb64=prefixer.qb64) + self.counselor.start(prefixer=prefixer, seqner=seqner, saider=saider, ghab=self.ghab) + self.counselor_started = True + + def _isCounselorComplete(self) -> bool: + """Check if Counselor has completed signature coordination.""" + prefixer = coring.Prefixer(qb64=self.ghab.pre) + seqner = coring.Seqner(sn=0) + saider = coring.Saider(qb64=self.ghab.pre) + return self.counselor.complete(prefixer=prefixer, seqner=seqner, saider=saider) + + +class MultisigInceptFollower(doing.DoDoer): + """ + Similar to `kli multisig join`. + Joins a multisig inception from a follower's perspective. + + The follower: + 1. Waits for /multisig/icp notification (via notifier) + 2. Creates matching GroupHab + 3. Signs and sends signature to others + 4. Starts Counselor to track completion + + Parameters: + hby: The Habery for this participant + mhab: The member Hab (single-sig AID) for this participant + group: Name for the new group AID (must match leader's) + postman: Poster for sending messages + counselor: Counselor for coordinating multisig + notifier: Notifier for receiving EXN messages + witReceiptor: WitnessReceiptor for getting receipts + auto: Whether to auto-approve (default True for tests) + """ + def __init__(self, hby: Habery, mhab: Hab, group: str, + postman: Poster, counselor: grouping.Counselor, + notifier: Notifier, witReceiptor: WitnessReceiptor, + auto: bool = True, **kwa): + self.hby = hby + self.mhab = mhab + self.group = group + self.postman = postman + self.counselor = counselor + self.notifier = notifier + self.witReceiptor = witReceiptor + self.auto = auto + self.ghab: GroupHab = None + self.started = False + self.done = False + self.pendingSends = [] # Track SAIDs of messages waiting for delivery confirmation + self.counselorStarted = False + + # Note: postman and counselor are NOT included here because they're already + # running via the controller context's doers (all_deeds). + super(MultisigInceptFollower, self).__init__(doers=[], **kwa) + + def recur(self, tyme, deeds=None): + """Main orchestration loop for joining a multisig inception.""" + super(MultisigInceptFollower, self).recur(tyme, deeds=deeds) + + # Step 1: Wait for /multisig/icp notification and create GroupHab + if self.ghab is None: + self._processInceptionNotifications() + return False + + # Step 2: Wait for sends to complete before starting Counselor + if not self._checkPendingSendsComplete(): + return False + + # Step 3: Start Counselor (once, after sends complete) + if not self.counselorStarted: + self._startCounselor() + return False + + # Step 4: Wait for Counselor to complete + if self._isCounselorComplete(): + print(f"[Follower {self.mhab.pre[:8]}] Multisig inception complete for {self.ghab.pre}", flush=True) + self.done = True + return True + + return False + + def _processInceptionNotifications(self): + """ + Scan notifications for /multisig/icp and create GroupHab. + + Uses noter.notes (persistent notifications) not signaler.signals + (transient pings). This pattern matches `kli multisig join`. + """ + if self.notifier.noter.notes.cntAll() == 0: + return # No notifications yet + + for keys, notice in self.notifier.noter.notes.getItemIter(): + if self._processIcpNotification(keys, notice): + break # Successfully processed one notification + + def _processIcpNotification(self, keys, notice) -> bool: + """ + Process a single /multisig/icp notification. + + Returns: + True if successfully processed, False to skip + """ + attrs = notice.attrs + route = attrs['r'] + + if route != '/multisig/icp': + return False # Not an inception notification + + exnSaid = attrs['d'] + exn, _ = exchanging.cloneMessage(self.hby, said=exnSaid) + + # Extract member info from payload + payload = exn.ked['a'] + smids = payload['smids'] + rmids = payload['rmids'] + + # Verify we're a participant + if self.mhab.pre not in smids: + raise ValueError(f"[Follower {self.mhab.pre[:8]}] Not in smids ({self.mhab.pre}), skipping. smids={smids}") + + # Extract inception parameters and create GroupHab + inits = self._extractInceptionParams(exn) + self.ghab = self.hby.makeGroupHab( + group=self.group, + mhab=self.mhab, + smids=smids, + rmids=rmids, + **inits + ) + + # Remove processed notification + self.notifier.noter.notes.rem(keys=keys) + + # Send our signature to others + self._sendSignatureToOthers(smids, rmids) + return True + + def _extractInceptionParams(self, exn: serdering.SerderKERI) -> dict: + """Extract GroupHab initialization parameters from the embedded ICP.""" + embeds = exn.ked['e'] + icpKed = embeds['icp'] + origIcp = serdering.SerderKERI(sad=icpKed) + + return dict( + isith=origIcp.ked["kt"], + nsith=origIcp.ked["nt"], + estOnly=kering.TraitCodex.EstOnly in origIcp.ked['c'], + DnD=kering.TraitCodex.DoNotDelegate in origIcp.ked['c'], + toad=origIcp.ked["bt"], + wits=origIcp.ked["b"], + delpre=origIcp.ked["di"] if "di" in origIcp.ked else None, + ) + + def _sendSignatureToOthers(self, smids: List[str], rmids: List[str]): + """Create and send our signed inception EXN to other members.""" + icp = self.ghab.makeOwnInception(allowPartiallySigned=True) + exn, ims = grouping.multisigInceptExn( + self.mhab, + smids=smids, + rmids=rmids, + icp=icp + ) + + others = [m for m in smids if m != self.mhab.pre] + for recpt in others: + self.postman.send( + src=self.mhab.pre, + dest=recpt, + topic="multisig", + serder=exn, + attachment=ims + ) + self.pendingSends.append(exn.said) + + def _checkPendingSendsComplete(self) -> bool: + """Check if all pending EXN sends have been delivered.""" + if not self.pendingSends: + return True + + for said in list(self.pendingSends): + if self.postman.sent(said=said): + self.pendingSends.remove(said) + HabHelpers.clearSentCue(self.postman, said) + + return not self.pendingSends + + def _startCounselor(self): + """Start the Counselor to coordinate signature collection.""" + prefixer = coring.Prefixer(qb64=self.ghab.pre) + seqner = coring.Seqner(sn=0) + saider = coring.Saider(qb64=prefixer.qb64) + self.counselor.start(prefixer=prefixer, seqner=seqner, saider=saider, ghab=self.ghab) + self.counselorStarted = True + + def _isCounselorComplete(self) -> bool: + """Check if Counselor has completed signature coordination.""" + prefixer = coring.Prefixer(qb64=self.ghab.pre) + seqner = coring.Seqner(sn=0) + saider = coring.Saider(qb64=self.ghab.pre) + return self.counselor.complete(prefixer=prefixer, seqner=seqner, saider=saider) + + +class MultisigDelegationApprover(doing.DoDoer): + """ + Approves delegation requests for a multisig delegator. + + This coordinates both members of the delegator multisig to: + 1. Watch the delegables escrow for delegation requests + 2. Create anchor events via interact (leader) or wait for coordination (follower) + 3. Coordinate signature collection + 4. Propagate to witnesses + + Parameters: + hby: The Habery for this delegator participant + ghab: The GroupHab for the delegator multisig + mhab: The member Hab for this participant + counselor: Counselor for coordinating multisig + witReceiptor: WitnessReceiptor for getting receipts + witq: WitnessInquisitor for querying witnesses + postman: Poster for sending messages + notifier: Notifier for receiving messages (follower mode) + interact: Whether to use interact (True) or rotate (False) for anchor + auto: Whether to auto-approve all delegation requests + leader: Whether this participant is the leader (creates events) + """ + + def __init__(self, hby: Habery, ghab: GroupHab, mhab: Hab, + counselor: grouping.Counselor, witReceiptor: WitnessReceiptor, + witq: WitnessInquisitor, postman: Poster, + notifier: Notifier = None, + interact: bool = True, auto: bool = True, leader: bool = True, **kwa): + self.hby = hby + self.ghab = ghab + self.mhab = mhab + self.counselor = counselor + self.witReceiptor = witReceiptor + self.witq = witq + self.postman = postman + self.notifier = notifier + self.interact = interact + self.auto = auto + self.leader = leader + self.approved = set() # Track approved delegation (pre, sn) tuples + # Track pending sends: {(pre, sn): {'said': exn_said, 'ixn_sn': sn, 'ixn_said': said}} + self.pendingSends = {} + # Track delegations ready for counselor start + self.readyForCounselor = {} + # Track delegations waiting for counselor completion + # {(pre, sn): {'ixn_sn': int, 'ixn_said': str, 'edig': bytes}} + self.waitingForComplete = {} + + # Note: counselor and postman are NOT included here because they're already + # running via the controller context's doers (all_deeds). + super(MultisigDelegationApprover, self).__init__(doers=[], **kwa) + + def delegablesEscrowed(self) -> List[Tuple[str, int, bytes]]: + """Get list of delegable events in escrow.""" + return [(pre, sn, edig) for (pre, sn), edig in self.hby.db.delegables.getItemIter()] + + def recur(self, tyme, deeds=None): + """ + Main orchestration loop for delegation approval. + + The approval process flows through these stages: + 1. Leader finds delegables → creates anchor → sends EXN to followers + 2. Followers receive EXN notification → sign same anchor → send EXN back + 3. Counselor coordinates signatures across all participants + 4. Once complete, release escrowed delegation by reprocessing with seal + """ + super(MultisigDelegationApprover, self).recur(tyme, deeds=deeds) + + # Process the approval pipeline + self._processPendingSends() + self._startCounselorForReadyDelegations() + self._releaseCompletedDelegations() + + # Leader creates anchors, follower signs from notifications + if self.leader: + self._leaderProcessDelegables() + else: + self._followerProcessNotifications() + + return False # Keep running + + def _processPendingSends(self): + """ + Check for EXN messages that have been successfully delivered. + + After the leader sends an anchor proposal to followers, we wait for + postman to confirm delivery before starting the Counselor coordination. + """ + for key in list(self.pendingSends.keys()): + info = self.pendingSends[key] + if self.postman.sent(said=info['said']): + self.readyForCounselor[key] = info + del self.pendingSends[key] + HabHelpers.clearSentCue(self.postman, info['said']) + + def _startCounselorForReadyDelegations(self): + """ + Start Counselor coordination for delegations that are ready. + + The Counselor collects signatures from all multisig participants + and marks the event complete when threshold is met. + """ + for key in list(self.readyForCounselor.keys()): + info = self.readyForCounselor[key] + prefixer = coring.Prefixer(qb64=self.ghab.pre) + seqner = coring.Seqner(sn=info['ixn_sn']) + saider = coring.Saider(qb64=info['ixn_said']) + + self.counselor.start(prefixer=prefixer, seqner=seqner, saider=saider, ghab=self.ghab) + self.waitingForComplete[key] = info + print(f"[DelegationApprover {self.mhab.pre[:8]}] Started counselor for anchor at sn={info['ixn_sn']}") + del self.readyForCounselor[key] + + def _releaseCompletedDelegations(self): + """ + Release escrowed delegations after anchor coordination completes. + + This is necessary because the delegables escrow has no automatic processor. + Delegation approval is an active policy decision (like `kli delegate confirm`). + + Once the multisig anchor is complete, we: + 1. Retrieve the escrowed DIP/DRT event + 2. Reprocess it with the delegation seal (delseqner, delsaider) attached + 3. Remove it from the delegables escrow + """ + for key in list(self.waitingForComplete.keys()): + info = self.waitingForComplete[key] + prefixer = coring.Prefixer(qb64=self.ghab.pre) + seqner = coring.Seqner(sn=info['ixn_sn']) + + if not self.counselor.complete(prefixer=prefixer, seqner=seqner): + continue + + self._releaseEscrowedDelegation(key, info) + self.approved.add(key) + del self.waitingForComplete[key] + + def _releaseEscrowedDelegation(self, key: Tuple[str, str], info: dict): + """ + Reprocess an escrowed DIP/DRT event with the delegation seal attached. + + Args: + key: (delegate_pre, delegate_sn) tuple identifying the escrowed event + info: dict with 'ixn_sn' and 'ixn_said' for the anchor event + """ + (pre, sn) = key + edigs = self.hby.db.delegables.get(keys=(pre, sn)) + if not edigs: + return + + edig = edigs[0] + dgkey = dbing.dgKey(pre.encode() if isinstance(pre, str) else pre, edig) + eraw = self.hby.db.getEvt(dgkey) + if not eraw: + return + + # Reconstruct the event with signatures + eserder = serdering.SerderKERI(raw=bytes(eraw)) + sigers = [indexing.Siger(qb64b=bytes(sig)) for sig in self.hby.db.getSigs(dgkey)] + wigers = [indexing.Siger(qb64b=bytes(sig)) for sig in self.hby.db.getWigs(dgkey)] + + # Reprocess with the delegation seal + # - now that all signatures and receipts exist event can be processed. + self.hby.kvy.processEvent( + serder=eserder, + sigers=sigers, + wigers=wigers, + delseqner=coring.Seqner(sn=info['ixn_sn']), + delsaider=coring.Saider(qb64=info['ixn_said']) + ) + + self.hby.db.delegables.rem(keys=(pre, sn), val=edig) # now remove from escrow since complete + print(f"[DelegationApprover {self.mhab.pre[:8]}] Released delegation for {pre[:8]} from escrow") + + def _leaderProcessDelegables(self): + """ + Leader: Find delegable events and create anchor proposals. + + The leader is responsible for: + 1. Scanning the delegables escrow for DIP/DRT events we need to approve + 2. Creating an IXN anchor event with the delegation seal + 3. Sending a /multisig/ixn EXN to all other multisig members + """ + for pre, sn, edig in self.delegablesEscrowed(): + key = (pre, sn) + if self._isAlreadyProcessing(key): + continue + + eserder = self._getValidDelegableEvent(pre, edig) + if eserder is None: + continue + + if not self.auto: + continue + + self._createAndSendAnchor(key, eserder) + + def _isAlreadyProcessing(self, key: Tuple[str, str]) -> bool: + """Check if this delegation is already being processed.""" + return (key in self.approved or + key in self.pendingSends or + key in self.readyForCounselor or + key in self.waitingForComplete) + + def _getValidDelegableEvent(self, pre: str, edig: bytes) -> serdering.SerderKERI: + """ + Get a delegable event if it's a valid DIP/DRT for our multisig. + + Returns: + SerderKERI if valid, None otherwise + """ + dgkey = dbing.dgKey(pre, edig) + eraw = self.hby.db.getEvt(dgkey) + if eraw is None: + return None + + eserder = serdering.SerderKERI(raw=bytes(eraw)) + ilk = eserder.sad['t'] + + # Must be a delegated event + if ilk not in (coring.Ilks.dip, coring.Ilks.drt): + return None + + # Get the delegator prefix + if ilk == coring.Ilks.dip: + delpre = eserder.sad['di'] + else: # drt + dkever = self.hby.kevers[eserder.pre] + delpre = dkever.delpre + + # We must be the delegator + if delpre != self.ghab.pre: + return None + + return eserder + + def _createAndSendAnchor(self, key: Tuple[str, str], eserder: serdering.SerderKERI): + """ + Create an anchor IXN and send /multisig/ixn EXN to other members. + + Args: + key: (delegate_pre, delegate_sn) tuple + eserder: The delegated event to approve + """ + print(f"[DelegationApprover {self.mhab.pre[:8]}] Found delegable {eserder.sad['t']} event for {eserder.pre[:8]}") + + # Create the delegation seal + anchor = HabHelpers.delegationSeal(eserder.ked['i'], eserder.snh, eserder.said) + + if not self.interact: + raise ValueError(f"[DelegationApprover {self.mhab.pre[:8]}] delegation approval via rotation not yet supported") + + # Create the anchor IXN (signs and stores locally) + ixn = self.ghab.interact(data=[anchor]) + ixnser = serdering.SerderKERI(raw=ixn) + + # Create and send the multisig coordination EXN + exn, ims = grouping.multisigInteractExn( + ghab=self.ghab, + aids=self.ghab.smids, + ixn=ixn + ) + + self._sendToOtherMembers(exn, ims) # sends exn notification to other members (followers) + + # Track for delivery confirmation + self.pendingSends[key] = { + 'said': exn.said, + 'ixn_sn': ixnser.sn, + 'ixn_said': ixnser.said + } + print(f"[DelegationApprover {self.mhab.pre[:8]}] Created anchor for {eserder.pre[:8]} at sn={ixnser.sn}, waiting for send confirmation") + + def _sendToOtherMembers(self, exn: serdering.SerderKERI, attachment: bytes): + """Send an EXN message to all other multisig members.""" + others = [m for m in self.ghab.smids if m != self.mhab.pre] + for recpt in others: + self.postman.send( + src=self.mhab.pre, + dest=recpt, + topic="multisig", + serder=exn, + attachment=attachment + ) + + def _followerProcessNotifications(self): + """ + Follower: Listen for /multisig/ixn notifications and co-sign the anchor. + + When the leader creates an anchor, they send a /multisig/ixn EXN to + all other members. The follower: + 1. Receives the notification via the Notifier + 2. Extracts the IXN data (which contains the delegation seal) + 3. Creates the SAME IXN locally (this signs it) + 4. Sends their own /multisig/ixn EXN back to coordinate signatures + 5. Starts the Counselor to complete coordination + """ + if self.notifier is None: + return + + for keys, notice in self.notifier.noter.notes.getItemIter(): + result = self._processIxnNotification(keys, notice) + if result: + # Successfully processed, remove the notification + self.notifier.noter.notes.rem(keys=keys) + + def _processIxnNotification(self, keys, notice) -> bool: + """ + Process a single /multisig/ixn notification. + + Returns: + True if successfully processed, False to skip + """ + attrs = notice.attrs + route = attrs.get('r') + + if route != '/multisig/ixn': + return False + + said = attrs.get('d') # EXN SAID + if said is None: + return False + + # Get the EXN message + exn, _ = exchanging.cloneMessage(self.hby, said=said) + if exn is None: + return False + + # Verify this is for our multisig group + payload = exn.ked.get('a', {}) + gid = payload.get('gid') + if gid != self.ghab.pre: + return False + + # Extract the embedded IXN data + embeds = exn.ked.get('e', {}) + ixn_data = embeds.get('ixn', {}) + if not ixn_data: + return False + + # Extract delegation info from the anchor seal + delegate_info = self._extractDelegateInfoFromAnchor(ixn_data) + if delegate_info is None: + return False + + delegate_pre, delegate_sn, anchor_data = delegate_info + + # Sign and coordinate + self._signAndCoordinateAnchor(delegate_pre, delegate_sn, anchor_data) + return True + + def _extractDelegateInfoFromAnchor(self, ixn_data: dict) -> Tuple[str, str, list]: + """ + Extract delegate prefix and sn from the anchor's seal data. + + The anchor IXN contains seals like: {'i': delegate_pre, 's': delegate_sn, 'd': delegate_said} + + Returns: + (delegate_pre, delegate_sn, anchor_data) or None if not found + """ + oixnser = serdering.SerderKERI(sad=ixn_data) + data = oixnser.ked.get('a', []) + + for seal in data: + if isinstance(seal, dict) and 'i' in seal and 's' in seal: + delegate_pre = seal['i'] + # Convert to 32-char hex string to match db.delegables key format + sn_int = int(seal['s'], 16) if isinstance(seal['s'], str) else seal['s'] + delegate_sn = f"{sn_int:032x}" + return (delegate_pre, delegate_sn, data) + + return None + + def _signAndCoordinateAnchor(self, delegate_pre: str, delegate_sn: str, anchor_data: list): + """ + Create the same anchor IXN locally and start coordination. + + By calling ghab.interact() with the same data, we create an event + with the same SAID as the leader's, which allows signature aggregation. + """ + # Create the SAME interaction event (this signs it locally) + ixn = self.ghab.interact(data=anchor_data) + ixnser = serdering.SerderKERI(raw=ixn) + + # Send our signing notification to others + exn_out, ims = grouping.multisigInteractExn( + ghab=self.ghab, + aids=self.ghab.smids, + ixn=ixn + ) + self._sendToOtherMembers(exn_out, ims) + + # Start Counselor coordination + prefixer = coring.Prefixer(qb64=self.ghab.pre) + seqner = coring.Seqner(sn=ixnser.sn) + saider = coring.Saider(qb64=ixnser.said) + self.counselor.start(prefixer=prefixer, seqner=seqner, saider=saider, ghab=self.ghab) + + # Track for completion (key must match delegables format) + self.waitingForComplete[(delegate_pre, delegate_sn)] = { + 'ixn_sn': ixnser.sn, + 'ixn_said': ixnser.said + } + + print(f"[DelegationApprover {self.mhab.pre[:8]}] Signed anchor from leader at sn={ixnser.sn} for delegate {delegate_pre[:8]}", flush=True) + + +class KeystateQueryDoer(doing.Doer): + """ + Queries for keystate to discover delegation approval anchor. + + Parameters: + hby: The Habery making the query + hab: The Hab making the query (source) + target_pre: The prefix to query for + target_sn: The sequence number to wait for (optional) + witq: WitnessInquisitor for making queries + wits: List of witness prefixes to query + """ + + def __init__(self, hby: Habery, hab: Hab, target_pre: str, + witq: WitnessInquisitor, wits: List[str] = None, + target_sn: int = None, **kwa): + self.hby = hby + self.hab = hab + self.target_pre = target_pre + self.target_sn = target_sn + self.witq = witq + self.wits = wits or [] + self.queried = False + super(KeystateQueryDoer, self).__init__(**kwa) + + def recur(self, tyme, deeds=None): + """Query and wait for keystate.""" + if not self.queried: + self._sendQuery() + + if self._hasRequiredKeystate(): + return True + + return False + + def _sendQuery(self): + """Send the keystate query to witnesses.""" + self.witq.query(src=self.hab.pre, pre=self.target_pre, wits=self.wits) + self.queried = True + print(f"[KeystateQuery] Querying for {self.target_pre[:8]}") + + def _hasRequiredKeystate(self) -> bool: + """Check if we have the required keystate.""" + if self.target_pre not in self.hby.kevers: + return False + + kever = self.hby.kevers[self.target_pre] + if self.target_sn is not None and kever.sn < self.target_sn: + return False + + print(f"[KeystateQuery] Found keystate for {self.target_pre[:8]} at sn={kever.sn}") + return True \ No newline at end of file diff --git a/tests/app/cli/test_kli_commands.py b/tests/app/cli/test_kli_commands.py index ec30511df..d1d8f1259 100644 --- a/tests/app/cli/test_kli_commands.py +++ b/tests/app/cli/test_kli_commands.py @@ -13,6 +13,7 @@ from keri.app.cli import commands from keri.app.cli.common import existing +from tests import conftest TEST_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -259,6 +260,9 @@ def test_incept_and_rotate_opts(helpers, capsys): """ Tests using the command line arguments for incept and the file argument for rotate """ + # Reload commands module to ensure fresh parser objects - see fn docs for explanation + conftest.reload_commands_module() + helpers.remove_test_dirs("test-opts") assert os.path.isdir("/usr/local/var/keri/ks/test-opts") is False diff --git a/tests/app/test_grouping.py b/tests/app/test_grouping.py index 39f8a1c4e..a2ce786d0 100644 --- a/tests/app/test_grouping.py +++ b/tests/app/test_grouping.py @@ -3,7 +3,10 @@ tests.app.grouping module """ -from contextlib import contextmanager +from collections import deque +from contextlib import contextmanager, ExitStack + +from hio.base import Doist from keri import kering, core from keri.app import habbing, grouping, notifying @@ -11,6 +14,11 @@ from keri.vdr import eventing as veventing from keri.db import dbing from keri.peer import exchanging +from tests.app.app_helpers import ( + openWit, openCtrlWited, HabHelpers, + MultisigInceptLeader, MultisigInceptFollower, + MultisigDelegationApprover, KeystateQueryDoer +) def test_counselor(): @@ -900,3 +908,293 @@ def test_multisig_interact_handler(mockHelpingNowUTC): prefixers = hby1.db.maids.get(keys=(esaid,)) assert len(prefixers) == 1 assert prefixers[0].qb64 == ghab2.mhab.pre + +def test_multisig_delegate(): + """ + End-to-end test for multisig delegation workflow. + + This test covers: + 1. A delegator multisig (dgt) formed by two single-sig participants (dgt1, dgt2) + 2. Two delegate participants (del1, del2) who create a delegated multisig (del) + 3. The delegates having OOBId with the delegator multisig + 4. Delegation approvals from both delegator single-sig AID participants + 5. Keystate queries from delegates to discover the delegation approval seal + 6. Generation of an OOBI for the multisig delegate + 7. Resolution of the multisig delegate OOBI by the delegator + 8. Verifications by the delegator about delegate state + """ + doist = Doist(limit=0.0, tock=0.03125, real=True) + + # Salts for deterministic key generation + DGT1_SALT = b'0ABaQTNARS1U1u7VhP0mnEK1' + DGT2_SALT = b'0ABaQTNARS1U1u7VhP0mnEK2' + DEL1_SALT = b'0AAB_Fidf5WeZf6VFc53IxV1' + DEL2_SALT = b'0AAB_Fidf5WeZf6VFc53IxV2' + + # Use ExitStack to open all contexts and flatten nesting + with ExitStack() as stack: + # Witness + wit_ctx = stack.enter_context(openWit(name='wan', tcpPort=6632, httpPort=6642)) + # delegator contexts - dgt1, dgt2 + dgt1_ctx = stack.enter_context(openCtrlWited(name='dgt1', salt=DGT1_SALT)) + dgt2_ctx = stack.enter_context(openCtrlWited(name='dgt2', salt=DGT2_SALT)) + # delegate contexts - del1, del2 + del1_ctx = stack.enter_context(openCtrlWited(name='del1', salt=DEL1_SALT)) + del2_ctx = stack.enter_context(openCtrlWited(name='del2', salt=DEL2_SALT)) + + # Enter all doers into the Doist + wit_deeds: deque = doist.enter(doers=wit_ctx.doers) + dgt1_deeds: deque = doist.enter(doers=dgt1_ctx.doers) + dgt2_deeds: deque = doist.enter(doers=dgt2_ctx.doers) + del1_deeds: deque = doist.enter(doers=del1_ctx.doers) + del2_deeds: deque = doist.enter(doers=del2_ctx.doers) + all_deeds = wit_deeds + dgt1_deeds + dgt2_deeds + del1_deeds + del2_deeds + + # Resolve witness OOBIs for all participants - rather than have witness OOBI in "iurls" in config + for ctx, name in [ + (dgt1_ctx, 'dgt1'), + (dgt2_ctx, 'dgt2'), + (del1_ctx, 'del1'), + (del2_ctx, 'del2')]: + HabHelpers.resolveOobi(doist, wit_deeds, ctx.hby, wit_ctx.oobi, alias='wan') + print(f" {name} resolved witness OOBI", flush=True) + + # Create single sig AIDs for delegator participants (dgt1, dgt2) + # dgt1 init + incept + dgt1_hab = dgt1_ctx.hby.makeHab(name='dgt1', isith='1', icount=1, toad=1, wits=[wit_ctx.pre]) + HabHelpers.collectWitnessReceipts(doist, all_deeds, dgt1_ctx.witReceiptor, dgt1_hab.pre) + + # dgt2 init + incept + dgt2_hab = dgt2_ctx.hby.makeHab(name='dgt2', isith='1', icount=1, toad=1, wits=[wit_ctx.pre]) + HabHelpers.collectWitnessReceipts(doist, all_deeds, dgt2_ctx.witReceiptor, dgt2_hab.pre) + + # OOBI Exchange between dgt1, dgt2 + dgt1_oobi = HabHelpers.generateOobi(dgt1_ctx.hby, alias='dgt1') + dgt2_oobi = HabHelpers.generateOobi(dgt2_ctx.hby, alias='dgt2') + + HabHelpers.resolveOobi(doist, all_deeds, dgt2_ctx.hby, dgt1_oobi, alias='dgt1') + HabHelpers.resolveOobi(doist, all_deeds, dgt1_ctx.hby, dgt2_oobi, alias='dgt2') + + # Create delegator multisig from del1, del2 + # smids and rmids are the same since all participants here are both signing and rotation members + dgt_smids = [dgt1_hab.pre, dgt2_hab.pre] + dgt_rmids = dgt_smids + + # dgt1 is the leader + dgt_leader = MultisigInceptLeader( + hby=dgt1_ctx.hby, + mhab=dgt1_hab, + smids=dgt_smids, + rmids=dgt_rmids, + group='dgt', + isith='2', + nsith='2', + toad=1, + wits=[wit_ctx.pre], + postman=dgt1_ctx.postman, + counselor=dgt1_ctx.counselor, + witReceiptor=dgt1_ctx.witReceiptor, + ) + + # dgt2 is the follower + dgt_follower = MultisigInceptFollower( + hby=dgt2_ctx.hby, + mhab=dgt2_hab, + group='dgt', + postman=dgt2_ctx.postman, + counselor=dgt2_ctx.counselor, + notifier=dgt2_ctx.notifier, + witReceiptor=dgt2_ctx.witReceiptor, + ) + + # Run until multisig inception is complete + dgt_deeds = doist.enter(doers=[dgt_leader, dgt_follower]) + # Wait for both ghabs to be created and counselor to confirm completion + while dgt_leader.ghab is None or dgt_follower.ghab is None: + doist.recur(deeds=all_deeds + dgt_deeds) + # Now wait for counselor completion + prefixer = coring.Prefixer(qb64=dgt_leader.ghab.pre) + seqner = coring.Seqner(sn=0) + while not dgt1_ctx.counselor.complete(prefixer, seqner): + doist.recur(deeds=all_deeds + dgt_deeds) + dgt_ghab = dgt_leader.ghab + + # Verify dgt multisig exists and has correct properties + assert dgt_ghab is not None, "dgt multisig should exist" + assert dgt_ghab.pre in dgt1_ctx.hby.kevers, "dgt1 should have dgt kever" + assert dgt_ghab.pre in dgt2_ctx.hby.kevers, "dgt2 should have dgt kever" + assert len(dgt_ghab.smids) == 2, "dgt should have 2 signing members" + + # Create delegate participants del1, del2 + # Create del1 single-sig AID + del1_hab = del1_ctx.hby.makeHab(name='del1', isith='1', icount=1, toad=1, wits=[wit_ctx.pre]) + HabHelpers.collectWitnessReceipts(doist, all_deeds, del1_ctx.witReceiptor, del1_hab.pre) + + # Create del2 single-sig AID + del2_hab = del2_ctx.hby.makeHab(name='del2', isith='1', icount=1, toad=1, wits=[wit_ctx.pre]) + HabHelpers.collectWitnessReceipts(doist, all_deeds, del2_ctx.witReceiptor, del2_hab.pre) + + # Delegates resolve delegator (dgt) OOBI + dgt_oobi = HabHelpers.generateOobi(dgt1_ctx.hby, alias='dgt') + HabHelpers.resolveOobi(doist, all_deeds, del1_ctx.hby, dgt_oobi, alias='dgt') + HabHelpers.resolveOobi(doist, all_deeds, del2_ctx.hby, dgt_oobi, alias='dgt') + + # OOBI exchange between del1 and del2 + del1_oobi = HabHelpers.generateOobi(del1_ctx.hby, alias='del1') + del2_oobi = HabHelpers.generateOobi(del2_ctx.hby, alias='del2') + HabHelpers.resolveOobi(doist, all_deeds, del2_ctx.hby, del1_oobi, alias='del1') + HabHelpers.resolveOobi(doist, all_deeds, del1_ctx.hby, del2_oobi, alias='del2') + + # Create delegated multisig from del1 and del2 + del_smids = [del1_hab.pre, del2_hab.pre] + del_rmids = del_smids + + # del1 is the leader for the delegate multisig + del_leader = MultisigInceptLeader( + hby=del1_ctx.hby, + mhab=del1_hab, + smids=del_smids, + rmids=del_rmids, + group='del', + isith='2', + nsith='2', + toad=1, + wits=[wit_ctx.pre], + delpre=dgt_ghab.pre, # dgt is the delegator + postman=del1_ctx.postman, + counselor=del1_ctx.counselor, + witReceiptor=del1_ctx.witReceiptor, + ) + + # del2 is the follower for the delegate multisig + del_follower = MultisigInceptFollower( + hby=del2_ctx.hby, + mhab=del2_hab, + group='del', + postman=del2_ctx.postman, + counselor=del2_ctx.counselor, + notifier=del2_ctx.notifier, + witReceiptor=del2_ctx.witReceiptor, + ) + + del_deeds = doist.enter(doers=[del_leader, del_follower]) + + # Run until the delegate sends the DIP to the delegator + # This will escrow until delegation is approved + while del_leader.ghab is None: + doist.recur(deeds=all_deeds + del_deeds) + del_ghab = del_leader.ghab + + # Delegators approve delegation (dgt1 and dgt2 confirm) + # Wait for delegation request to appear in delegables escrow + while not HabHelpers.hasDelegables(dgt1_ctx.hby.db): + doist.recur(deeds=all_deeds + del_deeds) + + # Both delegator participants approve (dgt1 is leader, dgt2 is follower) + dgt1_approver = MultisigDelegationApprover( + hby=dgt1_ctx.hby, + ghab=dgt_ghab, + mhab=dgt1_hab, + counselor=dgt1_ctx.counselor, + witReceiptor=dgt1_ctx.witReceiptor, + witq=dgt1_ctx.witq, + postman=dgt1_ctx.postman, + leader=True, # dgt1 is the leader + ) + dgt2_approver = MultisigDelegationApprover( + hby=dgt2_ctx.hby, + ghab=dgt2_ctx.hby.habByName('dgt'), # dgt2's copy of dgt + mhab=dgt2_hab, + counselor=dgt2_ctx.counselor, + witReceiptor=dgt2_ctx.witReceiptor, + witq=dgt2_ctx.witq, + postman=dgt2_ctx.postman, + notifier=dgt2_ctx.notifier, + leader=False, # dgt2 is the follower + ) + approver_deeds = doist.enter(doers=[dgt1_approver, dgt2_approver]) + + # Run until delegation is approved (anchor event created) + # Check for the anchor event on the delegator + while dgt_ghab.kever.sn < 1: + doist.recur(deeds=all_deeds + del_deeds + approver_deeds) + + # Get witness receipts for the anchor + HabHelpers.collectWitnessReceipts(doist, all_deeds + approver_deeds, dgt1_ctx.witReceiptor, dgt_ghab.pre, sn=dgt_ghab.kever.sn) + + # Wait for counselor to complete the anchor + prefixer = coring.Prefixer(qb64=dgt_ghab.pre) + seqner = coring.Seqner(sn=dgt_ghab.kever.sn) + while not dgt1_ctx.counselor.complete(prefixer, seqner): + doist.recur(deeds=all_deeds + del_deeds + approver_deeds) + + # Allow approvers to release the escrowed DIP event from delegables + # After counselor completes, the approver's _releaseCompletedDelegations() + # needs to run to reprocess the DIP with the delegation seal attached + while del_ghab.pre not in dgt1_ctx.hby.kevers or del_ghab.pre not in dgt2_ctx.hby.kevers: + doist.recur(deeds=all_deeds + del_deeds + approver_deeds) + + # Delegates query delegator keystate to discover approval anchor and complete delegation + del1_query = KeystateQueryDoer( + hby=del1_ctx.hby, + hab=del1_hab, + target_pre=dgt_ghab.pre, + target_sn=dgt_ghab.kever.sn, + witq=del1_ctx.witq, + wits=[wit_ctx.pre], + ) + del2_query = KeystateQueryDoer( + hby=del2_ctx.hby, + hab=del2_hab, + target_pre=dgt_ghab.pre, + target_sn=dgt_ghab.kever.sn, + witq=del2_ctx.witq, + wits=[wit_ctx.pre], + ) + query_deeds = doist.enter(doers=[del1_query, del2_query]) + + # Run until queries complete - check by looking at the kever in del1's database + while dgt_ghab.pre not in del1_ctx.hby.kevers or del1_ctx.hby.kevers[dgt_ghab.pre].sn < 1: + doist.recur(deeds=all_deeds + del_deeds + query_deeds) + + # Now the del multisig inception should complete - wait for counselor + prefixer = coring.Prefixer(qb64=del_ghab.pre) + seqner = coring.Seqner(sn=0) + while not del1_ctx.counselor.complete(prefixer, seqner): + doist.recur(deeds=all_deeds + del_deeds) + + # Verify del delegated multisig exists and has correct properties + assert del_ghab is not None, "del multisig should exist" + assert del_ghab.kever.delpre == dgt_ghab.pre, "del delegator should be dgt" + assert del_ghab.pre in del1_ctx.hby.kevers, "del1 should have del kever" + assert del_ghab.pre in del2_ctx.hby.kevers, "del2 should have del kever" + + # Before delegators can verify the delegate multisig's events, they need + # the public keys of the multisig members (del1, del2) to verify signatures. + del1_oobi = HabHelpers.generateOobi(del1_ctx.hby, alias='del1') + del2_oobi = HabHelpers.generateOobi(del2_ctx.hby, alias='del2') + HabHelpers.resolveOobi(doist, all_deeds, dgt1_ctx.hby, del1_oobi, alias='del1') + HabHelpers.resolveOobi(doist, all_deeds, dgt1_ctx.hby, del2_oobi, alias='del2') + HabHelpers.resolveOobi(doist, all_deeds, dgt2_ctx.hby, del1_oobi, alias='del1') + HabHelpers.resolveOobi(doist, all_deeds, dgt2_ctx.hby, del2_oobi, alias='del2') + + # Now delegators query for the delegate's KEL - this should work because + # eventing.py now checks for existing seals before escrowing to delegables + dgt1_ctx.witq.query(src=dgt1_hab.pre, pre=del_ghab.pre, sn=0, wits=[wit_ctx.pre]) + dgt2_ctx.witq.query(src=dgt2_hab.pre, pre=del_ghab.pre, sn=0, wits=[wit_ctx.pre]) + + # Wait for delegate to appear in delegator kevers + while del_ghab.pre not in dgt1_ctx.hby.kevers or del_ghab.pre not in dgt2_ctx.hby.kevers: + doist.recur(deeds=all_deeds) + + # Assertions - Verify delegator knows about delegate + assert del_ghab.pre in dgt1_ctx.hby.kevers, "dgt1 should know about del after witness query" + assert del_ghab.pre in dgt2_ctx.hby.kevers, "dgt2 should know about del after witness query" + + # Verify delegation anchor exists + assert dgt_ghab.kever.sn == 1, "dgt should have two events, icp and ixn (with dip approval anchor)" + assert del_ghab.kever.sn == 0, "delegate should have exactly one event - dip" + + # Verify delegables escrow is empty (delegation was properly released) + assert not HabHelpers.hasDelegables(dgt1_ctx.hby.db), "dgt1 delegables escrow should be empty" + assert not HabHelpers.hasDelegables(dgt2_ctx.hby.db), "dgt2 delegables escrow should be empty" \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index bcf7f5d0d..68ad84e01 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,9 @@ import os import shutil import multicommand +import sys +import importlib +import pkgutil import pytest from hio.base import doing @@ -388,3 +391,23 @@ def cmdDo(self, tymth, tock=0.0): yield self.tock return True + +def reload_commands_module(): + """ + Reload the commands module and all its submodules to ensure fresh parser objects. + + This is necessary because multicommand modifies parsers in-place by calling + add_subparsers(), and Python 3.12+ doesn't allow calling add_subparsers() + multiple times on the same parser object. + """ + # Reload all submodules first + for importer, modname, ispkg in pkgutil.walk_packages( + path=commands.__path__, + prefix=commands.__name__ + ".", + onerror=lambda x: None + ): + if modname in sys.modules: + importlib.reload(sys.modules[modname]) + # Then reload the main module + importlib.reload(commands) + diff --git a/tests/core/test_delegating.py b/tests/core/test_delegating.py index 2f183f3e2..5d64dc196 100644 --- a/tests/core/test_delegating.py +++ b/tests/core/test_delegating.py @@ -8,7 +8,9 @@ from keri import help from keri import kering, core -from keri.core import coring, eventing, parsing +from keri.core import coring, eventing, parsing, serdering +from keri.core.eventing import MissingDelegableApprovalError +import pytest from keri.app import keeping, habbing @@ -769,8 +771,245 @@ def test_delegables_escrow(): assert len(torHab.db.delegables.get(keys=snKey(gateHab.kever.serder.preb, gateHab.kever.serder.sn))) == 0 assert gateHab.pre in torKvy.kevers +def test_get_delegation_seal(): + """ + Test Kevery._getDelegationSeal: + 1. Seal found in AES + 2. Seal not in AES, dip event, delpre exists, seal found in KEL + 3. Seal not in AES, dip event, delpre is empty + 4. Seal not in AES, dip event, delpre exists, seal not found in KEL + 5. Seal not in AES, drt event, kever exists, delpre exists, seal found in KEL + 6. Seal not in AES, drt event, kever doesn't exist + 7. Seal not in AES, drt event, kever exists, delpre exists, seal not found in KEL + 8. Seal not in AES, event is neither dip nor drt + """ + bobSalt = core.Salter(raw=b'0123456789abcdef').qb64 + delSalt = core.Salter(raw=b'abcdef0123456789').qb64 + + with (basing.openDB(name="bob") as bobDB, + keeping.openKS(name="bob") as bobKS, + basing.openDB(name="del") as delDB, + keeping.openKS(name="del") as delKS, + keeping.openKS(name="fake") as fakeKS): + + # Init key pair managers + bobMgr = keeping.Manager(ks=bobKS, salt=bobSalt) + delMgr = keeping.Manager(ks=delKS, salt=delSalt) + + # Init Keverys + bobKvy = eventing.Kevery(db=bobDB) + delKvy = eventing.Kevery(db=delDB) + + # Setup Bob by creating inception event + verfers, digers = bobMgr.incept(stem='bob', temp=True) + bobSrdr = eventing.incept(keys=[verfer.qb64 for verfer in verfers], + ndigs=[diger.qb64 for diger in digers], + code=coring.MtrDex.Blake3_256) + + bob = bobSrdr.ked["i"] + bobMgr.move(old=verfers[0].qb64, new=bob) + + sigers = bobMgr.sign(ser=bobSrdr.raw, verfers=verfers) + msg = bytearray(bobSrdr.raw) + counter = core.Counter(core.Codens.ControllerIdxSigs, count=len(sigers), + gvrsn=kering.Vrsn_1_0) + msg.extend(counter.qb64b) + for siger in sigers: + msg.extend(siger.qb64b) + + # apply msg to bob's Kevery + parsing.Parser().parse(ims=bytearray(msg), kvy=bobKvy) + bobK = bobKvy.kevers[bob] + + # Setup Del's delegated inception event + verfers, digers = delMgr.incept(stem='del', temp=True) + delSrdr = eventing.delcept(keys=[verfer.qb64 for verfer in verfers], + delpre=bobK.prefixer.qb64, + ndigs=[diger.qb64 for diger in digers]) + + delPre = delSrdr.ked["i"] + delMgr.move(old=verfers[0].qb64, new=delPre) + + # Create delegating event for Bob + seal = eventing.SealEvent(i=delPre, + s=delSrdr.ked["s"], + d=delSrdr.said) + bobIxnSrdr = eventing.interact(pre=bobK.prefixer.qb64, + dig=bobK.serder.said, + sn=bobK.sn + 1, + data=[seal._asdict()]) + + sigers = bobMgr.sign(ser=bobIxnSrdr.raw, verfers=bobK.verfers) + msg = bytearray(bobIxnSrdr.raw) + counter = core.Counter(core.Codens.ControllerIdxSigs, count=len(sigers), + gvrsn=kering.Vrsn_1_0) + msg.extend(counter.qb64b) + for siger in sigers: + msg.extend(siger.qb64b) + + # apply msg to bob's Kevery + parsing.Parser().parse(ims=bytearray(msg), kvy=bobKvy) + assert bobK.serder.said == bobIxnSrdr.said + + # Create Saider for the interaction event + bobIxnSaider = coring.Saider(qb64=bobIxnSrdr.said) + + # Test 1: Seal found in AES + dgkey = dbing.dgKey(delPre.encode("utf-8"), delSrdr.saidb) + seqner = coring.Seqner(sn=bobK.sn) + couple = seqner.qb64b + bobIxnSaider.qb64b + bobKvy.db.setAes(dgkey, couple) + + result_seqner, result_saider = bobKvy._getDelegationSeal(eserder=delSrdr, dgkey=dgkey) + assert result_seqner.sn == seqner.sn + assert result_saider.qb64 == bobIxnSaider.qb64 + + # Test 2: Seal not in AES, dip event, delpre exists, seal found in KEL + # Remove from AES to test KEL lookup + bobKvy.db.delAes(dgkey) + # Seal should be found in KEL via fetchLastSealingEventByEventSeal + result_seqner, result_saider = bobKvy._getDelegationSeal(eserder=delSrdr, dgkey=dgkey) + assert result_seqner.sn == bobK.sn + assert result_saider.qb64 == bobIxnSaider.qb64 + + # Test 3: Seal not in AES, dip event, delpre is empty + # Create a dip event with empty delpre by manually creating the sad dict + # and then creating SerderKERI with verify=False + tempDelSrdr = eventing.delcept(keys=[verfer.qb64 for verfer in verfers], + delpre=bob, # valid delpre for creation + ndigs=[diger.qb64 for diger in digers]) + # Create a copy of the sad and set delpre to empty + badSad = dict(tempDelSrdr.sad) + badSad['di'] = "" # set delpre to empty + # Create SerderKERI from the modified sad with verify=False + badDelSrdr = serdering.SerderKERI(sad=badSad, verify=False) + badDgkey = dbing.dgKey(badDelSrdr.pre.encode("utf-8"), badDelSrdr.saidb) + with pytest.raises(MissingDelegableApprovalError) as exc_info: + bobKvy._getDelegationSeal(eserder=badDelSrdr, dgkey=badDgkey) + assert "Empty or missing delegator" in str(exc_info.value) + + # Test 4: Seal not in AES, dip event, delpre exists, seal not found in KEL + # Create a dip event with valid delpre but no seal in KEL + # Use a different Manager with different salt and KS to create a different delegate prefix + fakeSalt = core.Salter(raw=b'fakedelegate012345').qb64 + fakeMgr = keeping.Manager(ks=fakeKS, salt=fakeSalt) + fakeVerfers, fakeDigers = fakeMgr.incept(stem='fake', temp=True) + fakeDelSrdr = eventing.delcept(keys=[verfer.qb64 for verfer in fakeVerfers], + delpre=bob, # valid delpre + ndigs=[diger.qb64 for diger in fakeDigers]) + fakeDgkey = dbing.dgKey(fakeDelSrdr.pre.encode("utf-8"), fakeDelSrdr.saidb) + # Ensure no seal exists in KEL for this event (it's a different delegate) + with pytest.raises(MissingDelegableApprovalError) as exc_info: + bobKvy._getDelegationSeal(eserder=fakeDelSrdr, dgkey=fakeDgkey) + assert "No delegation seal found for event" in str(exc_info.value) + + # Test 5: Seal not in AES, drt event, kever exists, delpre exists, seal found in KEL + # First, create a valid dip event and process it so we have a kever + sigers = delMgr.sign(ser=delSrdr.raw, verfers=verfers) + msg = bytearray(delSrdr.raw) + counter = core.Counter(core.Codens.ControllerIdxSigs, count=len(sigers), + gvrsn=kering.Vrsn_1_0) + msg.extend(counter.qb64b) + for siger in sigers: + msg.extend(siger.qb64b) + counter = core.Counter(core.Codens.SealSourceCouples, count=1, + gvrsn=kering.Vrsn_1_0) + msg.extend(counter.qb64b) + seqner = coring.Seqner(sn=bobK.sn) + msg.extend(seqner.qb64b) + msg.extend(bobIxnSaider.qb64b) + + # Process the dip event so we have a kever for the delegate + parsing.Parser().parse(ims=bytearray(msg), kvy=bobKvy) + assert delPre in bobKvy.kevers + delK = bobKvy.kevers[delPre] + + # Now create a drt event + verfers, digers = delMgr.rotate(pre=delPre, temp=True) + delRotSrdr = eventing.deltate(pre=delK.prefixer.qb64, + keys=[verfer.qb64 for verfer in verfers], + dig=delK.serder.said, + sn=delK.sn + 1, + ndigs=[diger.qb64 for diger in digers]) + + # Create delegating interaction event for the rotation + rotSeal = eventing.SealEvent(i=delPre, + s=delRotSrdr.ked["s"], + d=delRotSrdr.said) + bobRotIxnSrdr = eventing.interact(pre=bobK.prefixer.qb64, + dig=bobK.serder.said, + sn=bobK.sn + 1, + data=[rotSeal._asdict()]) + + sigers = bobMgr.sign(ser=bobRotIxnSrdr.raw, verfers=bobK.verfers) + msg = bytearray(bobRotIxnSrdr.raw) + counter = core.Counter(core.Codens.ControllerIdxSigs, count=len(sigers), + gvrsn=kering.Vrsn_1_0) + msg.extend(counter.qb64b) + for siger in sigers: + msg.extend(siger.qb64b) + + # Process the delegated rotation event + parsing.Parser().parse(ims=bytearray(msg), kvy=bobKvy) + + # Test KEL lookup for drt event + bobRotIxnSaider = coring.Saider(qb64=bobRotIxnSrdr.said) + drtDgkey = dbing.dgKey(delPre.encode("utf-8"), delRotSrdr.saidb) + result_seqner, result_saider = bobKvy._getDelegationSeal(eserder=delRotSrdr, dgkey=drtDgkey) + assert result_seqner.sn == bobK.sn + assert result_saider.qb64 == bobRotIxnSaider.qb64 + + # Test 6: Seal not in AES, drt event, kever doesn't exist + # Create a drt event for a delegate we don't have a kever for + # First create a valid delegate prefix by creating a dip event + fakeVerfers, fakeDigers = fakeMgr.incept(stem='fake2', temp=True) + fakeDipSrdr = eventing.delcept(keys=[verfer.qb64 for verfer in fakeVerfers], + delpre=bob, + ndigs=[diger.qb64 for diger in fakeDigers]) + fakeDelPre = fakeDipSrdr.pre # valid prefix + fakeMgr.move(old=fakeVerfers[0].qb64, new=fakeDelPre) # move key to prefix + # Now create a drt event for this delegate (but kever doesn't exist in bobKvy) + fakeRotVerfers, fakeRotDigers = fakeMgr.rotate(pre=fakeDelPre, temp=True) + fakeDrtSrdr = eventing.deltate(pre=fakeDelPre, + keys=[verfer.qb64 for verfer in fakeRotVerfers], + dig=fakeDipSrdr.said, # use the dip said as prior + sn=1, + ndigs=[diger.qb64 for diger in fakeRotDigers]) + fakeDrtDgkey = dbing.dgKey(fakeDelPre.encode("utf-8"), fakeDrtSrdr.saidb) + with pytest.raises(MissingDelegableApprovalError) as exc_info: + bobKvy._getDelegationSeal(eserder=fakeDrtSrdr, dgkey=fakeDrtDgkey) + assert "No kever found for delegated rotation event" in str(exc_info.value) + + # Test 7: Seal not in AES, drt event, kever exists, delpre exists, seal not found in KEL + # Create a drt event with valid kever and delpre but no seal in KEL + fakeRotSrdr = eventing.deltate(pre=delK.prefixer.qb64, + keys=[verfer.qb64 for verfer in verfers], + dig=delK.serder.said, + sn=delK.sn + 2, # different sn, so no seal + ndigs=[diger.qb64 for diger in digers]) + fakeRotDgkey = dbing.dgKey(delPre.encode("utf-8"), fakeRotSrdr.saidb) + with pytest.raises(MissingDelegableApprovalError) as exc_info: + bobKvy._getDelegationSeal(eserder=fakeRotSrdr, dgkey=fakeRotDgkey) + assert "No delegation seal found for event" in str(exc_info.value) + + # Test 8: Seal not in AES, event is neither dip nor drt + # Create a regular icp event (not dip) + icpVerfers, icpDigers = bobMgr.incept(stem='icp', temp=True) + icpSrdr = eventing.incept(keys=[verfer.qb64 for verfer in icpVerfers], + ndigs=[diger.qb64 for diger in icpDigers], + code=coring.MtrDex.Blake3_256) + icpDgkey = dbing.dgKey(icpSrdr.pre.encode("utf-8"), icpSrdr.saidb) + with pytest.raises(MissingDelegableApprovalError) as exc_info: + bobKvy._getDelegationSeal(eserder=icpSrdr, dgkey=icpDgkey) + assert "No delegation seal found for event" in str(exc_info.value) + + assert not os.path.exists(delKS.path) + assert not os.path.exists(delDB.path) + assert not os.path.exists(bobKS.path) + assert not os.path.exists(bobDB.path) if __name__ == "__main__": test_delegation() test_delegation_supersede() + test_get_delegation_seal()