From bb9e781d74e3b1fd6286930cc7e2f05c365dc379 Mon Sep 17 00:00:00 2001 From: Kent Bull Date: Tue, 12 May 2026 15:44:37 -0600 Subject: [PATCH 1/2] fix,feat: use pndigs for group rotate;rotated=True for group sign Signed-off-by: Kent Bull --- src/signify/app/aiding.py | 5 +- src/signify/core/keeping.py | 57 ++- tests/app/test_aiding.py | 59 +++ tests/core/test_keeping.py | 354 +++++++++++++++++- .../test_multisig_prior_next_ondex.py | 160 ++++++++ 5 files changed, 621 insertions(+), 14 deletions(-) create mode 100644 tests/integration/test_multisig_prior_next_ondex.py diff --git a/src/signify/app/aiding.py b/src/signify/app/aiding.py index 9f20815..9681dd6 100644 --- a/src/signify/app/aiding.py +++ b/src/signify/app/aiding.py @@ -220,7 +220,10 @@ def rotate(self, name, *, transferable=True, nsith=None, toad=None, cuts=None, a cuts=cuts, adds=adds, data=data) - sigs = keeper.sign(ser=serder.raw) + sign_kwargs = dict(ser=serder.raw) + if keeper.algo == Algos.group: + sign_kwargs["rotated"] = True + sigs = keeper.sign(**sign_kwargs) body = dict( rot=serder.ked, diff --git a/src/signify/core/keeping.py b/src/signify/core/keeping.py index 6f853ae..8a7377b 100644 --- a/src/signify/core/keeping.py +++ b/src/signify/core/keeping.py @@ -64,7 +64,8 @@ def get(self, aid, **kwargs): elif keeping.Algos.group in aid: kwargs = aid[keeping.Algos.group] - return GroupKeeper(mgr=self, **kwargs) + pndigs = aid.get("state", {}).get("n") or kwargs.get("ndigs") + return GroupKeeper(mgr=self, pndigs=pndigs, **kwargs) elif keeping.Algos.extern in aid: extnprms = aid[keeping.Algos.extern] @@ -270,7 +271,7 @@ def rotate(self, ncodes, transferable, **_): return verfers, digers - def sign(self, ser, indexed=True, indices=None, ondices=None): + def sign(self, ser, indexed=True, indices=None, ondices=None, **_): """ Sign provided data using the current signing keys for AID Args: @@ -362,9 +363,24 @@ def signers(self): class GroupKeeper(BaseKeeper): + """Map group signing indexes onto the local member AID's keeper. + + A group keeper does not own group private keys. It receives group event key + lists from multisig ``states``/``rstates`` and delegates actual signing to + the local member habitat. ``rstates`` supply proposed next digests for the + event being built; they are not the signer authorization set. + + For rotations, KERI dual-index signatures expose two positions: ``index`` + in the event's current key list, and ``ondex`` in the prior establishment + event's next digest list. For inception, interaction, and other + non-rotation payloads, signatures satisfy only the current signing + threshold and do not expose ``ondex``. Direct low-level callers signing a + group rotation must pass ``rotated=True``; normal API callers get that from + ``Identifiers.rotate``. + """ def __init__(self, mgr: Manager, mhab=None, states=None, rstates=None, - keys=None, ndigs=None): + keys=None, ndigs=None, pndigs=None): self.mgr = mgr if states is not None: @@ -375,6 +391,9 @@ def __init__(self, mgr: Manager, mhab=None, states=None, rstates=None, self.gkeys = keys self.gdigs = ndigs + # Group prior next digests authorize the next rotation. On inception + # there is no separate prior event, so fall back to the proposed digests. + self.gpndigs = pndigs if pndigs is not None else ndigs self.mhab = mhab def incept(self, **_): @@ -386,16 +405,42 @@ def rotate(self, states, rstates, **_): return self.gkeys, self.gdigs - def sign(self, ser, indexed=True, **_): + def sign(self, ser, indexed=True, rotated=False, **_): key = self.mhab['state']['k'][0] - ndig = self.mhab['state']['n'][0] csi = self.gkeys.index(key) - pni = self.gdigs.index(ndig) + if rotated: + # Rotation signatures must expose the signer's position in the + # prior establishment event's precommitted next digest list. + pni = self._priorNextIndexForKey(key) + else: + # Non-rotation signatures are current-only. The event's `n` field + # is a precommitment for a future rotation, not an authorization + # set for deriving `ondex`. + pni = None mkeeper = self.mgr.get(self.mhab) return mkeeper.sign(ser, indexed=indexed, indices=[csi], ondices=[pni]) + def _priorNextIndexForKey(self, key): + """Return the prior-next index for the supplied current signing key. + + During rotation, KERI validators compare the signer key against the + prior establishment event's precommitted next digests. The digest code + comes from each prior next digest, so mirror that lookup locally before + producing the dual-index signature. + """ + for idx, pdig in enumerate(self.gpndigs or []): + prior = coring.Diger(qb64=pdig) + verfer = coring.Verfer(qb64=key) + exposed = coring.Diger(ser=verfer.qb64b, code=prior.code).qb64 + if exposed == pdig: + return idx + + raise ValueError( + "current signing key is not committed in the group prior next digests" + ) + def signers(self): """Return the current signers for the member habitat backing this group.""" return self.mgr.get(self.mhab).signers() diff --git a/tests/app/test_aiding.py b/tests/app/test_aiding.py index 284ed38..63f2138 100644 --- a/tests/app/test_aiding.py +++ b/tests/app/test_aiding.py @@ -516,6 +516,65 @@ def test_aiding_rotate(): unstub() +def test_aiding_rotate_group_passes_rotated_to_keeper(): + from signify.app.clienting import SignifyClient + mock_client = mock(spec=SignifyClient, strict=True) + + from signify.core import keeping + mock_manager = mock(spec=keeping.Manager, strict=True) + mock_client.manager = mock_manager # type: ignore + + from signify.app.aiding import Identifiers + ids = Identifiers(client=mock_client) # type: ignore + + mock_hab = {'prefix': 'hab prefix', 'name': 'group1', + 'state': {'s': '0', 'd': 'hab digest', 'b': [], 'k': ['key1'], 'kt': '1'}, + 'group': {'mhab': {'name': 'member1'}, 'keys': ['key1'], 'ndigs': ['ndig1']}} + expect(ids, times=1).get('group1').thenReturn(mock_hab) + + mock_keeper = mock( + {'algo': 'group', 'params': lambda: {'mhab': {'name': 'member1'}, 'keys': ['key1'], 'ndigs': ['ndig2']}}, + spec=keeping.GroupKeeper, + strict=True, + ) + expect(mock_manager, times=1).get(mock_hab).thenReturn(mock_keeper) + + keys = ['key1'] + ndigs = ['ndig2'] + states = [{'i': 'member1', 'k': ['key1'], 'n': ['ndig1']}] + rstates = [{'i': 'member1', 'k': ['key1'], 'n': ['ndig2']}] + expect(mock_keeper, times=1).rotate(ncodes=['A'], transferable=True, states=states, rstates=rstates).thenReturn( + (keys, ndigs) + ) + + from keri.core import serdering + mock_serder = mock({'ked': {'a': 'key event dictionary'}, 'raw': b'serder raw bytes'}, spec=serdering.SerderKERI, + strict=True) + + from keri.core import eventing + expect(eventing, times=1).rotate(pre='hab prefix', keys=['key1'], dig='hab digest', sn=1, isith='1', nsith='1', + ndigs=['ndig2'], toad=None, wits=[], + cuts=[], adds=[], data=[]).thenReturn(mock_serder) + + expect(mock_keeper, times=1).sign(ser=mock_serder.raw, rotated=True).thenReturn(['a signature']) + + from requests import Response + mock_response = mock(spec=Response, strict=True) + expected_data = {'rot': {'a': 'key event dictionary'}, 'sigs': ['a signature'], + 'group': {'mhab': {'name': 'member1'}, 'keys': ['key1'], 'ndigs': ['ndig2']}, + 'smids': ['member1'], 'rmids': ['member1']} + expect(mock_client, times=1).post('/identifiers/group1/events', json=expected_data).thenReturn(mock_response) + expect(mock_response, times=1).json().thenReturn({'success': 'yay'}) + + # Group rotation is the one app path that must ask the keeper to expose + # prior-next ondex; single-sig and external keepers remain untouched. + _, _, out = ids.rotate(name='group1', states=states, rstates=rstates) + assert out['success'] == 'yay' + + verifyNoUnwantedInteractions() + unstub() + + def test_aiding_rotate_randy(): from signify.app.clienting import SignifyClient mock_client = mock(spec=SignifyClient, strict=True) diff --git a/tests/core/test_keeping.py b/tests/core/test_keeping.py index e05b54e..d5d57dc 100644 --- a/tests/core/test_keeping.py +++ b/tests/core/test_keeping.py @@ -199,8 +199,55 @@ def test_keeping_manager_get_group(): from keri.core import coring expect(coring, times=1).Prefixer(qb64='aid1 prefix').thenReturn(mock_prefixer) - expect(keeping, times=1).GroupKeeper(mgr=manager, keys=['key1'], ndigs=['dig1']).thenReturn(mock_keeper) - actual = manager.get({'prefix': 'aid1 prefix', 'group': {'keys': ['key1'], 'ndigs': ['dig1']}}) + expect( + keeping, + times=1, + ).GroupKeeper( + mgr=manager, + keys=['key1'], + ndigs=['dig1'], + pndigs=['dig1'], + ).thenReturn(mock_keeper) + actual = manager.get({ + 'prefix': 'aid1 prefix', + 'group': {'keys': ['key1'], 'ndigs': ['dig1']}, + }) + + assert actual is mock_keeper + + verifyNoUnwantedInteractions() + unstub() + +def test_keeping_manager_get_group_uses_state_next_digests_for_prior_next(): + from keri.core.signing import Salter + mock_salter = mock(spec=Salter, strict=True) + + from signify.core.keeping import Manager + manager = Manager(salter=mock_salter) + + from signify.core import keeping + mock_keeper = mock(spec=keeping.GroupKeeper, strict=True) + + from keri.core.coring import Prefixer + mock_prefixer = mock(spec=Prefixer, strict=True) + + from keri.core import coring + expect(coring, times=1).Prefixer(qb64='aid1 prefix').thenReturn(mock_prefixer) + + expect( + keeping, + times=1, + ).GroupKeeper( + mgr=manager, + keys=['key1'], + ndigs=['persisted dig'], + pndigs=['state dig'], + ).thenReturn(mock_keeper) + actual = manager.get({ + 'prefix': 'aid1 prefix', + 'state': {'n': ['state dig']}, + 'group': {'keys': ['key1'], 'ndigs': ['persisted dig']}, + }) assert actual is mock_keeper @@ -780,6 +827,7 @@ def test_group_keeper(): assert gk.gkeys == ['key 1'] assert gk.gdigs == ['n dig 1'] + assert gk.gpndigs == ['n dig 1'] assert gk.mhab == {'m': 'hab'} from keri.app.keeping import Algos @@ -829,14 +877,274 @@ def test_group_keeper_sign(): mock_manager = Manager(salter=mock_salter) from signify.core.keeping import GroupKeeper - gk = GroupKeeper(mgr=mock_manager, mhab={'state': {'k': ['key 1'], 'n': ['n dig 1']}}, keys=['key 1'], ndigs=['n dig 1']) + gk = GroupKeeper( + mgr=mock_manager, + mhab={'state': {'k': ['key 1'], 'n': ['n dig 1']}}, + keys=['key 1'], + ndigs=['n dig 1'], + ) mock_keeper = mock(strict=True) - expect(mock_manager, times=1).get({'state': {'k': ['key 1'], 'n': ['n dig 1']}}).thenReturn(mock_keeper) + expect( + mock_manager, + times=1, + ).get({'state': {'k': ['key 1'], 'n': ['n dig 1']}}).thenReturn(mock_keeper) + + expect( + mock_keeper, + times=1, + ).sign(b'ser', indexed=True, indices=[0], ondices=[None]).thenReturn(['signatures']) + + # Unknown/non-rotation payloads must be current-only: index comes from the + # current key list, and ondex stays None instead of being inferred from n. + actual = gk.sign(b'ser', indexed=True) - expect(mock_keeper, times=1).sign(b'ser', indexed=True, indices=[0], ondices=[0]).thenReturn(['signatures']) + assert actual == ['signatures'] - actual = gk.sign(b'ser', indexed=True) + verifyNoUnwantedInteractions() + unstub() + +def test_group_keeper_sign_inception_is_current_only_when_local_next_digest_is_absent(): + from keri.core import eventing + from keri.core.coring import Diger + from keri.core.signing import Salter + + from signify.core.keeping import GroupKeeper + + salter = Salter(raw=b'0123456789abcdef') + signers = [ + salter.signer(path=f"member-{idx}", transferable=True) + for idx in range(3) + ] + keys = [signer.verfer.qb64 for signer in signers] + next_digests = [Diger(ser=signer.verfer.qb64b).qb64 for signer in signers] + + states = [ + {'i': f'member-{idx}', 'k': [key], 'n': [ndig]} + for idx, (key, ndig) in enumerate(zip(keys[:2], next_digests[:2])) + ] + rstates = [ + {'i': 'member-1', 'k': [keys[1]], 'n': [next_digests[1]]}, + {'i': 'member-2', 'k': [keys[2]], 'n': [next_digests[2]]}, + ] + # Regression guard: the local member signs from current keys but is absent + # from proposed next digests, so icp must not derive an ondex from rstates. + icp = eventing.incept( + keys=keys[:2], + isith='1', + nsith='1', + ndigs=[state['n'][0] for state in rstates], + toad='0', + wits=[], + ) + + mhab = {'state': {'k': [keys[0]], 'n': [next_digests[0]]}} + mock_manager = mock(strict=True) + mock_keeper = mock(strict=True) + gk = GroupKeeper( + mgr=mock_manager, + mhab=mhab, + states=states, + rstates=rstates, + ) + + expect(mock_manager, times=1).get(mhab).thenReturn(mock_keeper) + expect( + mock_keeper, + times=1, + ).sign(icp.raw, indexed=True, indices=[0], ondices=[None]).thenReturn(['signatures']) + + actual = gk.sign(icp.raw, indexed=True) + + assert actual == ['signatures'] + + verifyNoUnwantedInteractions() + unstub() + +def test_group_keeper_sign_interaction_is_current_only_without_next_digest_lookup(): + from keri.core import eventing + from keri.core.coring import Diger + from keri.core.signing import Salter + + from signify.core.keeping import GroupKeeper + + salter = Salter(raw=b'0123456789abcdef') + signers = [ + salter.signer(path=f"member-{idx}", transferable=True) + for idx in range(3) + ] + keys = [signer.verfer.qb64 for signer in signers] + next_digests = [Diger(ser=signer.verfer.qb64b).qb64 for signer in signers] + # Exclude the local member from proposed next digests to prove ixn signing + # ignores gdigs/rstates and remains current-only. + icp = eventing.incept( + keys=keys[:2], + isith='1', + nsith='1', + ndigs=next_digests[1:], + toad='0', + wits=[], + ) + ixn = eventing.interact( + pre=icp.pre, + sn=1, + data=[], + dig=icp.said, + ) + + mhab = {'state': {'k': [keys[0]], 'n': [next_digests[0]]}} + mock_manager = mock(strict=True) + mock_keeper = mock(strict=True) + gk = GroupKeeper( + mgr=mock_manager, + mhab=mhab, + keys=keys[:2], + ndigs=next_digests[1:], + ) + + expect(mock_manager, times=1).get(mhab).thenReturn(mock_keeper) + expect( + mock_keeper, + times=1, + ).sign(ixn.raw, indexed=True, indices=[0], ondices=[None]).thenReturn(['signatures']) + + actual = gk.sign(ixn.raw, indexed=True) + + assert actual == ['signatures'] + + verifyNoUnwantedInteractions() + unstub() + +def test_group_keeper_sign_rotation_uses_prior_next_digests(): + from keri.core import eventing + from keri.core.signing import Salter + from keri.core.coring import Diger + + from signify.core.keeping import GroupKeeper + + salter = Salter(raw=b'0123456789abcdef') + signers = [ + salter.signer(path=f"member-{idx}", transferable=True) + for idx in range(4) + ] + keys = [signer.verfer.qb64 for signer in signers] + prior_next_digests = [ + Diger(ser=signer.verfer.qb64b).qb64 + for signer in signers[:3] + ] + proposed_next_digests = [ + prior_next_digests[0], + prior_next_digests[1], + Diger(ser=signers[3].verfer.qb64b).qb64, + ] + states = [ + {'k': [key], 'n': [ndig]} + for key, ndig in zip(keys[:3], prior_next_digests) + ] + rstates = [ + {'k': [keys[0]], 'n': [proposed_next_digests[0]]}, + {'k': [keys[1]], 'n': [proposed_next_digests[1]]}, + {'k': [keys[3]], 'n': [proposed_next_digests[2]]}, + ] + icp = eventing.incept( + keys=keys[:3], + isith='3', + nsith='3', + ndigs=prior_next_digests, + toad='0', + wits=[], + ) + rot = eventing.rotate( + pre=icp.pre, + keys=keys[:3], + dig=icp.said, + sn=1, + isith='3', + nsith='3', + ndigs=proposed_next_digests, + toad='0', + wits=[], + ) + + mhab = {'state': {'k': [keys[2]], 'n': [prior_next_digests[2]]}} + mock_manager = mock(strict=True) + mock_keeper = mock(strict=True) + gk = GroupKeeper( + mgr=mock_manager, + mhab=mhab, + keys=keys[:3], + ndigs=prior_next_digests, + pndigs=prior_next_digests, + ) + gk.rotate(states=states, rstates=rstates) + + expect(mock_manager, times=1).get(mhab).thenReturn(mock_keeper) + expect( + mock_keeper, + times=1, + ).sign(rot.raw, indexed=True, indices=[2], ondices=[2]).thenReturn(['signatures']) + + actual = gk.sign(rot.raw, indexed=True, rotated=True) + + assert gk.gdigs == proposed_next_digests + assert gk.gpndigs == prior_next_digests + assert actual == ['signatures'] + + verifyNoUnwantedInteractions() + unstub() + +def test_group_keeper_sign_rotated_flag_uses_prior_next_without_parsing_event(): + from keri.core.signing import Salter + from keri.core.coring import Diger + + from signify.core.keeping import GroupKeeper + + salter = Salter(raw=b'0123456789abcdef') + signers = [ + salter.signer(path=f"member-{idx}", transferable=True) + for idx in range(4) + ] + keys = [signer.verfer.qb64 for signer in signers] + prior_next_digests = [ + Diger(ser=signer.verfer.qb64b).qb64 + for signer in signers[:3] + ] + proposed_next_digests = [ + prior_next_digests[0], + prior_next_digests[1], + Diger(ser=signers[3].verfer.qb64b).qb64, + ] + states = [ + {'k': [key], 'n': [ndig]} + for key, ndig in zip(keys[:3], prior_next_digests) + ] + rstates = [ + {'k': [keys[0]], 'n': [proposed_next_digests[0]]}, + {'k': [keys[1]], 'n': [proposed_next_digests[1]]}, + {'k': [keys[3]], 'n': [proposed_next_digests[2]]}, + ] + + mhab = {'state': {'k': [keys[2]], 'n': [prior_next_digests[2]]}} + mock_manager = mock(strict=True) + mock_keeper = mock(strict=True) + gk = GroupKeeper( + mgr=mock_manager, + mhab=mhab, + keys=keys[:3], + ndigs=prior_next_digests, + pndigs=prior_next_digests, + ) + gk.rotate(states=states, rstates=rstates) + + expect(mock_manager, times=1).get(mhab).thenReturn(mock_keeper) + expect( + mock_keeper, + times=1, + ).sign(b'not a keri event', indexed=True, indices=[2], ondices=[2]).thenReturn(['signatures']) + + # Regression guard: rotated=True is the protocol decision. Group signing + # must not depend on parsing the serialized event to expose prior-next ondex. + actual = gk.sign(b'not a keri event', indexed=True, rotated=True) assert actual == ['signatures'] @@ -851,7 +1159,12 @@ def test_group_keeper_params(): mock_manager = Manager(salter=mock_salter) from signify.core.keeping import GroupKeeper - gk = GroupKeeper(mgr=mock_manager, mhab={'state': {'k': ['key 1'], 'n': ['n dig 1']}}, keys=['key 1'], ndigs=['n dig 1']) + gk = GroupKeeper( + mgr=mock_manager, + mhab={'state': {'k': ['key 1'], 'n': ['n dig 1']}}, + keys=['key 1'], + ndigs=['n dig 1'], + ) actual = gk.params() @@ -902,6 +1215,33 @@ def test_base_keeper_sign_indexed(indexed, indices, ondices): assert actual[0] == 'an indexed signature' +def test_base_keeper_sign_indexed_current_only_ondex_none(): + from signify.core.keeping import BaseKeeper + + from keri.core.signing import Signer + mock_signer_one = mock(spec=Signer, strict=True) + + from keri.core import Siger + mock_cigar = mock({'qb64': 'a current-only indexed signature'}, spec=Siger, strict=True) + expect(mock_signer_one, times=1).sign( + b'ser bytes', + index=0, + only=True, + ondex=None, + ).thenReturn(mock_cigar) + + # BaseKeeper is the low-level path group keepers delegate through; None + # means "suppress ondex" and must produce a current-only indexed signature. + actual = BaseKeeper.__sign__( + b'ser bytes', + [mock_signer_one], + indexed=True, + indices=[0], + ondices=[None], + ) + + assert actual[0] == 'a current-only indexed signature' + @pytest.mark.parametrize('indexed,indices,ondices,expected', [ (True, [-1], [0], 'Invalid signing index = -1, not whole number.'), (True, [0], [-1], 'Invalid other signing index = -1, not None or not whole number.'), diff --git a/tests/integration/test_multisig_prior_next_ondex.py b/tests/integration/test_multisig_prior_next_ondex.py new file mode 100644 index 0000000..12f2844 --- /dev/null +++ b/tests/integration/test_multisig_prior_next_ondex.py @@ -0,0 +1,160 @@ +"""Multisig replacement rotation coverage for prior-next signing indexes.""" + +from __future__ import annotations + +import pytest +from keri.core import signing as csigning + +from .helpers import ( + accept_multisig_incept, + alias, + create_identifier, + query_key_state, + resolve_agent_oobi, + rotate_identifier, + start_multisig_incept, + wait_for_operation, +) + + +pytestmark = pytest.mark.integration + + +def test_3_of_3_replacement_rotation_signs_with_prior_next_ondexes(client_factory): + """A departing full-threshold member still signs from the prior next set.""" + clients = [client_factory() for _ in range(4)] + client_a, client_b, client_c, client_d = clients + member_names = [alias(f"ondex-member-{index + 1}") for index in range(4)] + member_a_name, member_b_name, member_c_name, member_d_name = member_names + group_name = alias("ondex-group") + + member_a = create_identifier(client_a, member_a_name, wits=[]) + member_b = create_identifier(client_b, member_b_name, wits=[]) + member_c = create_identifier(client_c, member_c_name, wits=[]) + member_d = create_identifier(client_d, member_d_name, wits=[]) + members = [member_a, member_b, member_c, member_d] + + # The three current group participants must know all existing and incoming + # member KELs before they can build the same replacement rotation inputs. + _resolve_oobis_for_replacement( + targets=[ + (client_a, member_a_name), + (client_b, member_b_name), + (client_c, member_c_name), + ], + sources=[ + (client_a, member_a_name), + (client_b, member_b_name), + (client_c, member_c_name), + (client_d, member_d_name), + ], + ) + + participants = [member_a["prefix"], member_b["prefix"], member_c["prefix"]] + operation_a, _ = start_multisig_incept( + client_a, + group_name=group_name, + local_member_name=member_a_name, + participants=participants, + isith=3, + nsith=3, + toad=0, + wits=[], + ) + operation_b = accept_multisig_incept( + client_b, + group_name=group_name, + local_member_name=member_b_name, + ) + operation_c = accept_multisig_incept( + client_c, + group_name=group_name, + local_member_name=member_c_name, + ) + wait_for_operation(client_a, operation_a, timeout=20) + wait_for_operation(client_b, operation_b, timeout=20) + wait_for_operation(client_c, operation_c, timeout=20) + + # Rotate only the current group members. The group inception precommitted + # to these next keys, so these rotated member states are the current signer + # keys for the next group rotation. + rotate_identifier(client_a, member_a_name) + rotate_identifier(client_b, member_b_name) + rotate_identifier(client_c, member_c_name) + + ( + member_a_state, + member_b_state, + member_c_state, + member_d_state, + ) = _query_replacement_states(client_a, members) + + # The other current participants need the same exact member KEL state so + # they can sign the same proposed replacement event locally. + _query_replacement_states(client_b, members) + _query_replacement_states(client_c, members) + + # This is the valid first replacement rotation: C is still in the current + # signing set, but D replaces C in the proposed next digest list. + states = [member_a_state, member_b_state, member_c_state] + rstates = [member_a_state, member_b_state, member_d_state] + + rot_a, sigs_a, _ = client_a.identifiers().rotate( + group_name, + states=states, + rstates=rstates, + ) + sig_a = csigning.Siger(qb64=sigs_a[0]) + assert rot_a.ked["n"] == [state["n"][0] for state in rstates] + assert sig_a.index == 0 + assert sig_a.ondex == 0 + + rot_b, sigs_b, _ = client_b.identifiers().rotate( + group_name, + states=states, + rstates=rstates, + ) + sig_b = csigning.Siger(qb64=sigs_b[0]) + assert rot_b.ked["n"] == [state["n"][0] for state in rstates] + assert sig_b.index == 1 + assert sig_b.ondex == 1 + + # This is the regression. C is intentionally absent from the proposed + # rstates, but C must still expose ondex=2 because C's current key was + # committed at position 2 in the group's prior next digest list. + rot_c, sigs_c, _ = client_c.identifiers().rotate( + group_name, + states=states, + rstates=rstates, + ) + sig_c = csigning.Siger(qb64=sigs_c[0]) + assert rot_c.ked["n"] == [state["n"][0] for state in rstates] + assert sig_c.index == 2 + assert sig_c.ondex == 2 + + +def _query_replacement_states(client, members: list[dict]) -> tuple[dict, dict, dict, dict]: + """Fetch exact sequence-number states for rotated A/B/C and unrotated D.""" + state_a = query_key_state(client, members[0]["prefix"], sn="1") + state_b = query_key_state(client, members[1]["prefix"], sn="1") + state_c = query_key_state(client, members[2]["prefix"], sn="1") + state_d = query_key_state(client, members[3]["prefix"], sn="0") + return state_a, state_b, state_c, state_d + + +def _resolve_oobis_for_replacement( + *, + targets: list[tuple[object, str]], + sources: list[tuple[object, str]], +) -> None: + """Resolve all replacement member agent OOBIs into current participants.""" + for target_client, _ in targets: + for source_client, source_name in sources: + if source_client is target_client: + continue + resolve_agent_oobi( + source_client, + source_name, + target_client, + alias=source_name, + ) From db19da1b143bc871bf3be41ddf3638aed66b5c4d Mon Sep 17 00:00:00 2001 From: Kent Bull Date: Tue, 12 May 2026 15:59:00 -0600 Subject: [PATCH 2/2] refactor: use this.gdigs for prior next digs (gpndigs) This is cleaner Signed-off-by: Kent Bull --- src/signify/core/keeping.py | 13 +++++++------ tests/core/test_keeping.py | 6 +----- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/signify/core/keeping.py b/src/signify/core/keeping.py index 8a7377b..d809ca7 100644 --- a/src/signify/core/keeping.py +++ b/src/signify/core/keeping.py @@ -64,8 +64,7 @@ def get(self, aid, **kwargs): elif keeping.Algos.group in aid: kwargs = aid[keeping.Algos.group] - pndigs = aid.get("state", {}).get("n") or kwargs.get("ndigs") - return GroupKeeper(mgr=self, pndigs=pndigs, **kwargs) + return GroupKeeper(mgr=self, **kwargs) elif keeping.Algos.extern in aid: extnprms = aid[keeping.Algos.extern] @@ -380,7 +379,7 @@ class GroupKeeper(BaseKeeper): """ def __init__(self, mgr: Manager, mhab=None, states=None, rstates=None, - keys=None, ndigs=None, pndigs=None): + keys=None, ndigs=None): self.mgr = mgr if states is not None: @@ -391,9 +390,11 @@ def __init__(self, mgr: Manager, mhab=None, states=None, rstates=None, self.gkeys = keys self.gdigs = ndigs - # Group prior next digests authorize the next rotation. On inception - # there is no separate prior event, so fall back to the proposed digests. - self.gpndigs = pndigs if pndigs is not None else ndigs + # Group prior next digests authorize the next rotation. On load, + # persisted group ndigs are expected to be the current establishment + # event's next digest list. On inception there is no separate prior + # event, so gpndigs starts as the same list as gdigs. + self.gpndigs = self.gdigs self.mhab = mhab def incept(self, **_): diff --git a/tests/core/test_keeping.py b/tests/core/test_keeping.py index d5d57dc..71204d9 100644 --- a/tests/core/test_keeping.py +++ b/tests/core/test_keeping.py @@ -206,7 +206,6 @@ def test_keeping_manager_get_group(): mgr=manager, keys=['key1'], ndigs=['dig1'], - pndigs=['dig1'], ).thenReturn(mock_keeper) actual = manager.get({ 'prefix': 'aid1 prefix', @@ -218,7 +217,7 @@ def test_keeping_manager_get_group(): verifyNoUnwantedInteractions() unstub() -def test_keeping_manager_get_group_uses_state_next_digests_for_prior_next(): +def test_keeping_manager_get_group_uses_group_ndigs_for_prior_next_snapshot(): from keri.core.signing import Salter mock_salter = mock(spec=Salter, strict=True) @@ -241,7 +240,6 @@ def test_keeping_manager_get_group_uses_state_next_digests_for_prior_next(): mgr=manager, keys=['key1'], ndigs=['persisted dig'], - pndigs=['state dig'], ).thenReturn(mock_keeper) actual = manager.get({ 'prefix': 'aid1 prefix', @@ -1074,7 +1072,6 @@ def test_group_keeper_sign_rotation_uses_prior_next_digests(): mhab=mhab, keys=keys[:3], ndigs=prior_next_digests, - pndigs=prior_next_digests, ) gk.rotate(states=states, rstates=rstates) @@ -1132,7 +1129,6 @@ def test_group_keeper_sign_rotated_flag_uses_prior_next_without_parsing_event(): mhab=mhab, keys=keys[:3], ndigs=prior_next_digests, - pndigs=prior_next_digests, ) gk.rotate(states=states, rstates=rstates)