From d77b185b5dc2124eb32bf2ddf15b9158295bd515 Mon Sep 17 00:00:00 2001 From: Kent Bull Date: Wed, 25 Mar 2026 15:51:57 -0600 Subject: [PATCH] Add booted agent validation Coded by Codex with prompts from Kent Bull --- src/signify/app/clienting.py | 19 +++++ tests/app/test_clienting.py | 81 ++++++++++++++++++- tests/integration/helpers.py | 5 +- .../test_provisioning_and_identifiers.py | 4 + 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/src/signify/app/clienting.py b/src/signify/app/clienting.py index 5964832..c992bde 100644 --- a/src/signify/app/clienting.py +++ b/src/signify/app/clienting.py @@ -71,11 +71,27 @@ def __init__(self, passcode, url=None, boot_url=None, tier=Tiers.low, extern_mod self.agent = None self.authn = None self.base = None + self._booted_agent = None self.ctrl = authing.Controller(bran=self.bran, tier=self.tier) self.url = url self.boot_url = boot_url + def _cache_booted_agent(self, state): + """Cache the agent state returned by ``/boot`` for first-connect checks.""" + try: + self._booted_agent = authing.Agent(state=state) + except (KeyError, kering.ValidationError) as ex: + raise kering.AuthNError(f"invalid agent state from boot response: {ex}") from ex + + def _require_booted_agent_match(self): + """Ensure first-connect approval targets the agent returned by ``/boot``.""" + if self._booted_agent is None: + return + + if self.agent.pre != self._booted_agent.pre or self.agent.said != self._booted_agent.said: + raise kering.ConfigurationError("booted agent does not match connected agent state") + def boot(self) -> dict: """Create the remote cloud agent delegated to this controller AID.""" evt, siger = self.ctrl.event() @@ -93,6 +109,7 @@ def boot(self) -> dict: body = res.json() except requests.exceptions.JSONDecodeError as ex: raise kering.AuthNError(f"invalid response from server: {ex}") from ex + self._cache_booted_agent(body) return body def connect(self, url=None): @@ -124,7 +141,9 @@ def connect(self, url=None): raise kering.ConfigurationError("commitment to controller AID missing in agent inception event") if self.ctrl.serder.sn == 0: + self._require_booted_agent_match() self.approveDelegation() + self._booted_agent = None self.authn = authing.Authenticater(agent=self.agent, ctrl=self.ctrl) self.session.auth = SignifyAuth(self.authn) diff --git a/tests/app/test_clienting.py b/tests/app/test_clienting.py index 78905a6..33f3ba3 100644 --- a/tests/app/test_clienting.py +++ b/tests/app/test_clienting.py @@ -10,6 +10,17 @@ from mockito import mock, patch, unstub, verify, verifyNoUnwantedInteractions, expect, ANY +def make_agent_state(pre="agent_prefix", said=None, delpre="a prefix"): + said = pre if said is None else said + return { + "i": pre, + "s": "0", + "d": said, + "di": delpre, + "k": ["DMZh_y-H5C3cSbZZST-fqnsmdNTReZxIh0t2xSTOJQ8a"], + } + + def test_signify_client_defaults(make_signify_client): from signify.app.clienting import SignifyClient patch(SignifyClient, 'connect', lambda: None) @@ -41,6 +52,27 @@ def test_signify_client_bad_passcode_length(): from signify.app.clienting import SignifyClient SignifyClient(passcode='too short') + +def test_signify_client_boot_caches_booted_agent(make_signify_client): + import requests + + client = make_signify_client(boot_url='http://boot.example') + + body = make_agent_state(pre="booted_agent", said="booted_said", delpre=client.controller) + response = mock({'status_code': requests.codes.accepted}, spec=requests.Response, strict=True) + expect(response, times=1).json().thenReturn(body) + expect(requests, times=1).post(url='http://boot.example/boot', json=ANY).thenReturn(response) + + out = client.boot() + + assert out == body + assert client._booted_agent is not None + assert client._booted_agent.pre == body["i"] + assert client._booted_agent.said == body["d"] + + verifyNoUnwantedInteractions() + unstub() + def test_signify_client_connect_no_delegation(make_signify_client, make_mock_session): from signify.core import authing from keri.core.coring import Tiers @@ -48,6 +80,7 @@ def test_signify_client_connect_no_delegation(make_signify_client, make_mock_ses expect(authing, times=1).Controller(bran='abcdefghijklmnop01234', tier=Tiers.low).thenReturn(mock_init_controller) client = make_signify_client() + client._booted_agent = authing.Agent(make_agent_state(pre="stale_boot_agent", said="stale_boot_said")) import requests mock_session = make_mock_session() @@ -58,7 +91,7 @@ def test_signify_client_connect_no_delegation(make_signify_client, make_mock_ses expect(client, times=1).states().thenReturn(mock_state) from signify.core import authing - mock_agent = mock({'delpre': 'a prefix'}, spec=authing.Agent, strict=True) + mock_agent = mock({'delpre': 'a prefix', 'pre': 'connected_agent', 'said': 'connected_said'}, spec=authing.Agent, strict=True) expect(authing, times=1).Agent(state=mock_state.agent).thenReturn(mock_agent) from keri.core import serdering @@ -83,6 +116,7 @@ def test_signify_client_connect_no_delegation(make_signify_client, make_mock_ses client.connect('http://example.com') assert client.pidx == mock_state.pidx + assert client._booted_agent.pre == "stale_boot_agent" assert client.session.auth == mock_signify_auth #type: ignore assert client.session.hooks == {'response': mock_authenticator.verify} #type: ignore @@ -97,6 +131,7 @@ def test_signify_client_connect_delegation(make_signify_client, make_mock_sessio expect(authing, times=1).Controller(bran='abcdefghijklmnop01234', tier=Tiers.low).thenReturn(mock_init_controller) client = make_signify_client() + client._booted_agent = authing.Agent(make_agent_state(pre="booted_agent", said="booted_said")) # setup for client.connect() import requests @@ -108,7 +143,7 @@ def test_signify_client_connect_delegation(make_signify_client, make_mock_sessio expect(client, times=1).states().thenReturn(mock_state) from signify.core import authing - mock_agent = mock({'delpre': 'a prefix'}, spec=authing.Agent, strict=True) + mock_agent = mock({'delpre': 'a prefix', 'pre': 'booted_agent', 'said': 'booted_said'}, spec=authing.Agent, strict=True) expect(authing, times=1).Agent(state=mock_state.agent).thenReturn(mock_agent) from keri.core import serdering @@ -134,6 +169,7 @@ def test_signify_client_connect_delegation(make_signify_client, make_mock_sessio expect(clienting, times=1).SignifyAuth(mock_authenticator).thenReturn(mock_signify_auth) client.connect('http://example.com') + assert client._booted_agent is None verifyNoUnwantedInteractions() unstub() @@ -163,7 +199,7 @@ def test_signify_client_connect_bad_delegation(): expect(client, times=1).states().thenReturn(mock_state) from signify.core import authing - mock_agent = mock({'delpre': 'a prefix'}, spec=authing.Agent, strict=True) + mock_agent = mock({'delpre': 'a prefix', 'pre': 'connected_agent', 'said': 'connected_said'}, spec=authing.Agent, strict=True) expect(authing, times=1).Agent(state=mock_state.agent).thenReturn(mock_agent) from keri.core import serdering @@ -184,6 +220,45 @@ def test_signify_client_connect_bad_delegation(): verifyNoUnwantedInteractions() unstub() + +def test_signify_client_connect_rejects_mismatched_booted_agent(make_signify_client, make_mock_session): + from signify.core import authing + from keri.core.coring import Tiers + mock_init_controller = mock(spec=authing.Controller, strict=True) + expect(authing, times=1).Controller(bran='abcdefghijklmnop01234', tier=Tiers.low).thenReturn(mock_init_controller) + + client = make_signify_client() + client._booted_agent = authing.Agent(make_agent_state(pre="booted_agent", said="booted_said")) + + import requests + mock_session = make_mock_session() + expect(requests, times=1).Session().thenReturn(mock_session) + + from signify.signifying import SignifyState + mock_state = mock({'pidx': 0, 'agent': 'agent info', 'controller': 'controller info'}, spec=SignifyState, strict=True) + expect(client, times=1).states().thenReturn(mock_state) + + mock_agent = mock({'delpre': 'a prefix', 'pre': 'connected_agent', 'said': 'connected_said'}, spec=authing.Agent, strict=True) + expect(authing, times=1).Agent(state=mock_state.agent).thenReturn(mock_agent) + + from keri.core import serdering + mock_serder = mock({'sn': 0}, spec=serdering.Serder, strict=True) + from keri.core import signing + mock_salter = mock(spec=signing.Salter, strict=True) + mock_controller = mock({'pre': 'a prefix', 'salter': mock_salter, 'serder': mock_serder}, spec=authing.Controller, strict=True) + expect(authing, times=1).Controller(bran='abcdefghijklmnop01234', tier=Tiers.low, state=mock_state.controller).thenReturn(mock_controller) + + from signify.core import keeping + mock_manager = mock(spec=keeping.Manager, strict=True) + expect(keeping, times=1).Manager(salter=mock_salter, extern_modules=None).thenReturn(mock_manager) + + from keri.kering import ConfigurationError + with pytest.raises(ConfigurationError, match='booted agent does not match connected agent state'): + client.connect('http://example.com') + + verifyNoUnwantedInteractions() + unstub() + def test_signify_client_approve_delegation(): from signify.core import authing from keri.core.coring import Tiers diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 296fc39..e8ece57 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -77,7 +77,9 @@ def boot_client_manually(client: SignifyClient, live_stack) -> dict: timeout=30, ) response.raise_for_status() - return response.json() + body = response.json() + client._cache_booted_agent(body) + return body def connect_client( @@ -109,6 +111,7 @@ def connect_client( else: raise ValueError(f"unsupported boot_mode={boot_mode}") assert isinstance(body, dict) + client._integration_boot_response = body client.connect() assert client.agent is not None client._integration_live_stack = live_stack diff --git a/tests/integration/test_provisioning_and_identifiers.py b/tests/integration/test_provisioning_and_identifiers.py index 7af7b51..e5ec693 100644 --- a/tests/integration/test_provisioning_and_identifiers.py +++ b/tests/integration/test_provisioning_and_identifiers.py @@ -40,6 +40,8 @@ def test_provision_agent_and_connect(client_factory): assert client.agent.pre assert client.agent.pre != client.controller assert client.agent.delpre == client.controller + assert client._integration_boot_response["i"] == client.agent.pre + assert client._integration_boot_response["d"] == client.agent.said assert client.session is not None assert client.session.auth is not None @@ -58,6 +60,8 @@ def test_manual_agent_boot_and_connect(client_factory): assert client.controller == client.ctrl.pre assert client.agent.pre assert client.agent.delpre == client.controller + assert client._integration_boot_response["i"] == client.agent.pre + assert client._integration_boot_response["d"] == client.agent.said assert client.session is not None