From 3db926e4801477641b8fc76da648665ad6b2a8e6 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Thu, 20 Nov 2025 21:06:56 -0500 Subject: [PATCH 01/21] update config and controller setup Signed-off-by: Patrick St-Louis --- webvh/README.md | 86 ++++++++- webvh/webvh/anoncreds/registry.py | 2 +- webvh/webvh/anoncreds/tests/test_registry.py | 2 +- webvh/webvh/config/config.py | 14 +- webvh/webvh/config/webvh_config_record.py | 11 ++ webvh/webvh/did/{manager.py => controller.py} | 153 ++++++--------- webvh/webvh/did/models/operations.py | 21 +- webvh/webvh/did/server_client.py | 34 ++++ .../did/tests/test_controller_manager.py | 39 +++- webvh/webvh/did/tests/test_witness_manager.py | 77 +++++++- webvh/webvh/did/utils.py | 1 + webvh/webvh/did/witness.py | 179 +++++++++++++++--- .../protocols/attested_resource/handlers.py | 2 +- webvh/webvh/protocols/log_entry/handlers.py | 2 +- webvh/webvh/protocols/routes.py | 116 ++++++++++-- webvh/webvh/routes.py | 75 +++++--- webvh/webvh/tests/test_routes.py | 51 +++++ 17 files changed, 668 insertions(+), 197 deletions(-) rename webvh/webvh/did/{manager.py => controller.py} (87%) diff --git a/webvh/README.md b/webvh/README.md index eac151ee5..6961e2ce0 100644 --- a/webvh/README.md +++ b/webvh/README.md @@ -109,12 +109,12 @@ You should get a status success response and the controller logs should say `Con ### Creating a DID -When creating a did, you need to provide at the minimum a namespace. An identifier can optionally be provided otherwise a random uuid will be generated. +When creating a did, you need to provide at the minimum a namespace. An alias can optionally be provided otherwise a random uuid will be generated. The resulting did will use these values as such: -`did:webvh::::` +`did:webvh::::` -Given a `server_url` of `https://example.com`, a full example for the `demo` `namespace` and the `01` `identifier` would look like: +Given a `server_url` of `https://example.com`, a full example for the `demo` `namespace` and the `01` `alias` would look like: `did:webvh::example.com:demo:01` The parameters values can be opted out to their default. @@ -123,14 +123,14 @@ The parameters values can be opted out to their default. ```json { "options": { - "identifier": "01", + "alias": "01", "namespace": "demo" } } ``` - - identifier - The identifier for the did. If this isn't provided it will be a randomly generated uuid. - - namespace - This is required and is the root identifier for the did. + - alias - The alias for the did. If this isn't provided it will be a randomly generated uuid. + - namespace - This is required and is the root alias for the did. - parameters - This is an optional object that can be used to set the did to be portable or prerotated. If portable is true the did will be able to be moved to another server. If prerotation is true the did will be able to be rotated by the controller. #### Auto attest response @@ -223,6 +223,80 @@ Witness - Attest a did doc - `POST /did/webvh/witness/registrations?id=did:web:e Controller - The controller gets a message `Received witness response: attested` and will finish creating the did. The did should now be resolvable and available in local storage. +## Protocols + +### Connecting to a witness service + +Once the controller is configured with a `server_url` and a `witness_id`, it can automatically discover and connect to a witness service: + +- **Server discovery**: The controller calls the WebVH server’s `/.well-known/did.json` via `WebVHServerClient.get_witness_services()` to obtain the list of witness services. +- **Service selection**: It locates the service whose `id` matches the configured `witness_id`. +- **Invitation resolution**: The controller reads the `serviceEndpoint` field from that service, which MUST be an out-of-band invitation URL (`... ?oob=...`). +- **Connection establishment**: The controller passes this invitation URL to `OutOfBandManager.receive_invitation(...)` to create and/or reuse a DIDComm connection to the witness. + +### Requesting a DID path + +To request a new DID path (domain + namespace + alias) from the WebVH server: + +- **Request**: The controller calls `WebVHServerClient.request_identifier(namespace, alias)`, which translates to an HTTP `GET` on: + - `GET {server_url}?namespace={namespace}&alias={alias}` +- **Server response**: The server returns a JSON containing: + - `parameters`: Method parameters **and server policies** that must be applied when creating the DID: + - `method` – the DID method identifier (e.g. `did:webvh:1.0`) that tells the controller which rules to follow. + - `witness.threshold` – the minimum number of witnesses that must attest each log entry. + - `witness.witnesses` – the list of witness identifiers (`id` values) that are allowed to attest for this DID. + - `watchers` – a list of mandatory watcher service URLs that should be notified on updates. + - `portability` – if `true`, the DID must be created as portable so it can be migrated between servers. + - `nextKeyHashes` – if present (as an empty list), indicates that prerotation is required and a `nextKeyHash` must be provisioned. + - `state.id`: A placeholder DID of the form `did:webvh:{SCID}:{domain}:{namespace}:{alias}`. + +This response is then used to create the preliminary DID document, update keys, and initial log entry. + +### Creating a log entry + +Log entries represent the DID’s history on the WebVH server: + +- **Preliminary document**: The controller builds a preliminary DID document with a fresh signing key, default authentication/assertion methods, and any requested DIDComm service entries. +- **Parameters**: It computes the method parameters (e.g. portability, witness threshold, watcher list, update/next keys). +- **Initial log entry**: Using `DocumentState.initial(...)`, the controller constructs the first history line (log entry) and signs it with the configured update key. +- **Witnessing**: + - If the method requires a witness, the controller sends a witness request to the configured witness (self-witnessing or remote). + - The witness returns a Data Integrity Proof over the log entry’s `versionId`, or the controller waits for an attestation event. +- **Publishing**: The controller calls `WebVHServerClient.submit_log_entry(log_entry, witness_signature)` to POST the log entry to the server. + +Subsequent updates (e.g. key rotation, deactivation) follow the same pattern using `DocumentState.create_next(...)` to generate the next history line. + +### Uploading an attested resource + +Generic attested resources (such as schemas, status lists, or other linked objects) follow a similar witness workflow: + +- **Preparation**: The controller (or resource author) builds the resource payload and signs it as required. +- **Witnessing**: + - For self-witnessing, the witness adds its own Data Integrity Proof directly to the resource. + - For remote witnessing, the author sends an attested resource witness request; the witness verifies the proof and appends its own proof. +- **Upload**: The author (or witness, depending on the flow) calls `WebVHServerClient.upload_attested_resource(...)`, which POSTs to the WebVH server: + - `POST {server_url}/{namespace}/{alias}/resources` + - Body: `{ "attestedResource": }` +- **Notification**: If watcher notifications are enabled, the controller uses `WebVHWatcherClient.notify_watchers(...)` to inform any configured watchers that a new resource has been published. + +### Updating WHOIS + +WHOIS data is published as a dedicated attested Verifiable Presentation (VP) linked to a DID: + +- **Preparation**: + - The controller (as holder) builds a WHOIS VP with `holder` set to the controller DID and one or more VCs describing WHOIS attributes. + - The presentation may already contain proofs on individual VCs (the controller can optionally verify these). +- **Controller signing**: + - The controller overwrites the `holder` field to ensure it matches the controller DID. + - It then signs the VP using its DID authentication key (Data Integrity Proof with proofPurpose `authentication`). +- **Upload to server**: + - The controller calls `update_whois` (ACAPy admin API), which internally invokes `WebVHServerClient.submit_whois(...)`. + - This results in a server call: + - `POST {server_url}/{namespace}/{alias}/whois` + - Body: `{ "verifiablePresentation": }` +- **Result**: + - The WHOIS VP becomes the authoritative WHOIS record for that DID on the WebVH server and can be resolved by clients that understand the WHOIS extension. + ### Updating a DID When updating a DID, you will usually modify the webvh parameters, add/remove a verification method or edit the services. diff --git a/webvh/webvh/anoncreds/registry.py b/webvh/webvh/anoncreds/registry.py index 11f3c8cf1..b5344fadc 100644 --- a/webvh/webvh/anoncreds/registry.py +++ b/webvh/webvh/anoncreds/registry.py @@ -57,7 +57,7 @@ from ..protocols.attested_resource.record import PendingAttestedResourceRecord from ..protocols.states import WitnessingState from ..did.witness import WitnessManager -from ..did.manager import ControllerManager +from ..did.controller import ControllerManager from ..did.utils import add_proof # from ..models.resources import AttestedResource diff --git a/webvh/webvh/anoncreds/tests/test_registry.py b/webvh/webvh/anoncreds/tests/test_registry.py index 684c49597..b7e3f264c 100644 --- a/webvh/webvh/anoncreds/tests/test_registry.py +++ b/webvh/webvh/anoncreds/tests/test_registry.py @@ -35,7 +35,7 @@ TEST_WITNESS_SEED, TEST_RESOLVER, ) -from ...did.manager import ControllerManager +from ...did.controller import ControllerManager from ..registry import DIDWebVHRegistry test_domain = "sandbox.bcvh.vonx.io" diff --git a/webvh/webvh/config/config.py b/webvh/webvh/config/config.py index 10ec11f0a..425af0fd7 100644 --- a/webvh/webvh/config/config.py +++ b/webvh/webvh/config/config.py @@ -22,6 +22,11 @@ def _get_wallet_identifier(profile: Profile): return profile.settings.get(WALLET_ID) or profile.settings.get(WALLET_NAME) +def get_global_plugin_config(profile: Profile): + """Get the global plugin settings.""" + return copy.deepcopy(profile.settings.get("plugin_config", {}).get("webvh", {})) + + async def get_plugin_config(profile: Profile): """Get the plugin settings.""" wallet_id = _get_wallet_identifier(profile) @@ -38,9 +43,14 @@ async def get_plugin_config(profile: Profile): pass if stored_config_record: - return json.loads(stored_config_record.value)["config"] + config = json.loads(stored_config_record.value)["config"] + else: + config = get_global_plugin_config(profile) - return copy.deepcopy(profile.settings.get("plugin_config", {}).get("webvh", {})) + if config is None: + config = {} + + return config async def set_config(profile: Profile, config: dict): diff --git a/webvh/webvh/config/webvh_config_record.py b/webvh/webvh/config/webvh_config_record.py index 3873ce889..48420809f 100644 --- a/webvh/webvh/config/webvh_config_record.py +++ b/webvh/webvh/config/webvh_config_record.py @@ -32,6 +32,17 @@ class Meta: required=False, description="Auto attest requests", default=False ) + witness_id = fields.Str( + required=False, + description="Preferred witness identifier", + ) + + auto_config = fields.Bool( + required=False, + description="Automatically configure controller and witness resources", + default=False, + ) + notify_watchers = fields.Bool( required=False, description="Notify watchers", default=False ) diff --git a/webvh/webvh/did/manager.py b/webvh/webvh/did/controller.py similarity index 87% rename from webvh/webvh/did/manager.py rename to webvh/webvh/did/controller.py index 4139ba942..5497b0f91 100644 --- a/webvh/webvh/did/manager.py +++ b/webvh/webvh/did/controller.py @@ -43,7 +43,7 @@ from ..protocols.log_entry.record import PendingLogEntryRecord from ..protocols.states import WitnessingState from .witness import WitnessManager -from .exceptions import DidCreationError, OperationError +from .exceptions import ConfigurationError, DidCreationError, OperationError from .server_client import WebVHServerClient, WebVHWatcherClient from .utils import ( decode_invitation, @@ -85,7 +85,12 @@ def __init__(self, profile: Profile) -> None: self.watcher_client = WebVHWatcherClient(self.profile) async def _get_active_witness_connection(self) -> Optional[ConnRecord]: - server_url = await get_server_url(self.profile) + try: + server_url = await get_server_url(self.profile) + except ConfigurationError: + # No server_url configured yet, so no active connection possible + return None + witness_alias = create_alias(url_to_domain(server_url), "witnessConnection") async with self.profile.session() as session: connection_records = await ConnRecord.retrieve_by_alias( @@ -122,9 +127,11 @@ async def _set_parameters_input(self, placeholder_id, options): # Witness # https://identity.foundation/didwebvh/next/#did-witnesses - if options.get("witnessThreshold", 0): + # Support both camelCase and snake_case for backward compatibility + witness_threshold = options.get("witnessThreshold") or options.get("witness_threshold", 0) + if witness_threshold: parameters["witness"] = { - "threshold": options.get("witnessThreshold"), + "threshold": witness_threshold, "witnesses": [ {"id": witness} for witness in await get_witnesses(self.profile) ], @@ -387,81 +394,48 @@ async def _apply_policy(self, parameters: dict, options: dict): return options - async def configure(self, options: dict) -> dict: + async def configure(self, config: dict) -> dict: """Configure did controller and/or witness.""" - config = await get_plugin_config(self.profile) - config["scids"] = config.get("scids", {}) - config["witnesses"] = config.get("witnesses", []) - config["witness"] = options.get("witness", False) - config["endorsement"] = options.get("endorsement", False) - config["auto_attest"] = options.get("auto_attest", False) - config["server_url"] = options.get("server_url", config.get("server_url")).rstrip( - "/" - ) - config["parameter_options"] = options.get("parameter_options", {}) - - if not config.get("server_url"): - raise OperationError("No server url configured.") - - await set_config(self.profile, config) + # Connect to witness service (only if not self-witness) + if witness_id := config.get("witness_id"): + await self.connect_to_witness(witness_id) - if config.get("witness", False): - # Create a local witness key to setup self witnessing - domain = url_to_domain(config["server_url"]) - key_alias = f"webvh:{domain}@witnessKey" - if options.get("witness_key", None): - witness_key = await bind_key( - self.profile, options.get("witness_key"), key_alias - ) - else: - witness_key = await find_key(self.profile, key_alias) or await create_key( - self.profile, key_alias - ) - if not witness_key: - raise OperationError("Error creating witness key.") - - witness_id = f"did:key:{witness_key}" - - else: - # Connect to witness service - witness_id = await self.connect_to_witness(options.get("witness_invitation")) - - if witness_id not in config["witnesses"]: - config["witnesses"].append(witness_id) - - await set_config(self.profile, config) + if witness_id not in config.get("witnesses", []): + config.setdefault("witnesses", []).append(witness_id) + await set_config(self.profile, config) return config - async def connect_to_witness(self, witness_invitation) -> None: + async def connect_to_witness(self, witness_id: str) -> str: """Process witness invitation and connect.""" - if not witness_invitation: - raise OperationError("No witness invitation provided.") - - try: - decoded_invitation = decode_invitation(witness_invitation) - except UnicodeDecodeError: - raise OperationError("Invalid witness invitation.") - - if ( - not decoded_invitation.get("goal").startswith("did:key:") - and not decoded_invitation.get("goal-code") == "witness-service" - ): + + with WebVHServerClient(self.profile) as server_client: + invitation = await server_client.get_witness_invitation(witness_id) + + if not invitation: + raise OperationError( + f"Witness {witness_id} not listed by server document." + ) + + if invitation.get("goal-code", None) != "witness-service": raise OperationError("Missing invitation goal-code and witness did.") + + if invitation.get("goal", None) != witness_id: + raise OperationError("Wrong invitation goal must match witness id.") # Get the witness connection is already set up if await self._get_active_witness_connection(): LOGGER.info("Connected to witness from previous connection.") - return decoded_invitation.get("goal") + return witness_id - try: + try: server_domain = await get_server_domain(self.profile) - alias = f"webvh:{server_domain}@witness" + witness_alias = f"webvh:{server_domain}@witness" await OutOfBandManager(self.profile).receive_invitation( - invitation=InvitationMessage.from_url(witness_invitation), + invitation=invitation, auto_accept=True, - alias=alias, + alias=witness_alias, ) except BaseModelError as err: raise OperationError(f"Error receiving witness invitation: {err}") @@ -469,15 +443,16 @@ async def connect_to_witness(self, witness_invitation) -> None: for _ in range(5): if await self._get_active_witness_connection(): LOGGER.info("Connected to witness agent.") - return decoded_invitation.get("goal") + return witness_id + await asyncio.sleep(1) LOGGER.info( "No immediate response when trying to connect to witness agent. You can " - f"try manually setting up a connection with alias {alias} or " + f"try manually setting up a connection with alias {witness_alias} or " "restart the agent when witness is available." ) - return decoded_invitation.get("goal") + return witness_id async def create(self, options: dict): """Create DID and first log entry.""" @@ -793,11 +768,8 @@ async def upload_resource(self, attested_resource, state, record_id): await self.server_client.upload_attested_resource(attested_resource) - async def auto_witness_setup(self) -> None: - """Automatically set up the witness the connection.""" - domain = await get_server_domain(self.profile) - witness_alias = create_alias(domain, "witnessConnection") - + async def auto_setup(self, config: dict | None = None) -> None: + """Automatically set up the witness connection for controllers.""" if not await is_controller(self.profile): return @@ -806,30 +778,19 @@ async def auto_witness_setup(self) -> None: LOGGER.info("Connected to witness from previous connection.") return - witness_invitation = (await get_plugin_config(self.profile)).get( - "witness_invitation" - ) - if not witness_invitation: - LOGGER.info("No witness invitation, can't create connection automatically.") - return - oob_mgr = OutOfBandManager(self.profile) - try: - await oob_mgr.receive_invitation( - invitation=InvitationMessage.from_url(witness_invitation), - auto_accept=True, - alias=witness_alias, - ) - except BaseModelError as err: - raise OperationError(f"Error receiving witness invitation: {err}") + config = config or await get_plugin_config(self.profile) + if not config.get("server_url"): + raise ConfigurationError("No server url configured.") - for _ in range(5): - if await self._get_active_witness_connection(): - LOGGER.info("Connected to witness agent.") - return - await asyncio.sleep(1) + witness_id = config.get("witness_id") - LOGGER.info( - "No immediate response when trying to connect to witness agent. You can " - f"try manually setting up a connection with alias {witness_alias} or " - "restart the agent when witness is available." - ) + if not witness_id: + LOGGER.info( + "No witness identifier, can't create connection automatically." + ) + return + + try: + await self.connect_to_witness(witness_id) + except OperationError as err: + LOGGER.info("Automatic witness connection failed: %s", err) \ No newline at end of file diff --git a/webvh/webvh/did/models/operations.py b/webvh/webvh/did/models/operations.py index 84e95640e..4b7166ab4 100644 --- a/webvh/webvh/did/models/operations.py +++ b/webvh/webvh/did/models/operations.py @@ -34,13 +34,6 @@ class ConfigureWebvhSchema(OpenAPISchema): }, default=False, ) - witness_key = fields.Str( - required=False, - metadata={ - "description": "Existing key to use as witness key", - "example": "z6MkgKA7yrw5kYSiDuQFcye4bMaJpcfHFry3Bx45pdWh3s8i", - }, - ) auto_attest = fields.Bool( required=False, metadata={ @@ -49,6 +42,14 @@ class ConfigureWebvhSchema(OpenAPISchema): }, default=False, ) + auto_config = fields.Bool( + required=False, + metadata={ + "description": "Automatically prepare controller and witness resources", + "example": "true", + }, + default=True, + ) endorsement = fields.Bool( required=False, metadata={ @@ -57,11 +58,11 @@ class ConfigureWebvhSchema(OpenAPISchema): }, default=False, ) - witness_invitation = fields.Str( + witness_id = fields.Str( required=False, metadata={ - "description": "An invitation from a witness, required for a controller", - "example": "http://localhost:3000?oob=eyJAdHlwZSI6ICJodHRwczovL2RpZGNvbW0ub3JnL291dC1vZi1iYW5kLzEuMS9pbnZpdGF0aW9uIiwgIkBpZCI6ICJlMzI5OGIyNS1mZjRlLTRhZmItOTI2Yi03ZDcyZmVlMjQ1ODgiLCAibGFiZWwiOiAid2VidmgtZW5kb3JzZXIiLCAiaGFuZHNoYWtlX3Byb3RvY29scyI6IFsiaHR0cHM6Ly9kaWRjb21tLm9yZy9kaWRleGNoYW5nZS8xLjAiXSwgInNlcnZpY2VzIjogW3siaWQiOiAiI2lubGluZSIsICJ0eXBlIjogImRpZC1jb21tdW5pY2F0aW9uIiwgInJlY2lwaWVudEtleXMiOiBbImRpZDprZXk6ejZNa3FDQ1pxNURSdkdMcDV5akhlZlZTa2JhN0tYWlQ1Nld2SlJacEQ2Z3RvRzU0I3o2TWtxQ0NacTVEUnZHTHA1eWpIZWZWU2tiYTdLWFpUNTZXdkpSWnBENmd0b0c1NCJdLCAic2VydmljZUVuZHBvaW50IjogImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCJ9XX0", + "description": "Preferred witness DID to connect with", + "example": "did:key:z6Mk....", }, ) diff --git a/webvh/webvh/did/server_client.py b/webvh/webvh/did/server_client.py index 87ff1604c..65a6ec845 100644 --- a/webvh/webvh/did/server_client.py +++ b/webvh/webvh/did/server_client.py @@ -39,6 +39,40 @@ def __init__(self, profile: Profile): """Initialize the WebVHServerClient with a profile.""" self.profile = profile + async def get_document(self): + """Get the server document.""" + async with ClientSession() as session: + response = await session.get( + f"{await get_server_url(self.profile)}/.well-known/did.json" + ) + return response.json() + + async def get_witness_services(self): + """Get the witness services from the server document.""" + document = await self.get_document() + return document.get("service", []) + + async def get_witness_invitation(self, witness_id: str): + """Get the witness invitation from the server document.""" + witness_services = await self.get_witness_services() + witness_service = next( + (svc for svc in witness_services if svc.get("id") == witness_id), None + ) + if not witness_service: + raise OperationError( + f"Witness {witness_id} not listed by server document." + ) + + invitation_url = witness_service.get("serviceEndpoint") + witness_key = witness_id.split(":")[-1] + if invitation_url != f"{await get_server_url(self.profile)}?_oobid={witness_key}": + raise OperationError("Witness service endpoint does not match server document.") + + async with ClientSession() as session: + response = await session.get(invitation_url) + + return response.json() + async def request_identifier(self, namespace, identifier) -> tuple: """Contact the webvh server to request an identifier.""" async with ClientSession() as session: diff --git a/webvh/webvh/did/tests/test_controller_manager.py b/webvh/webvh/did/tests/test_controller_manager.py index d5b1f642c..3beb838b4 100644 --- a/webvh/webvh/did/tests/test_controller_manager.py +++ b/webvh/webvh/did/tests/test_controller_manager.py @@ -3,6 +3,7 @@ from acapy_agent.core.event_bus import EventBus from acapy_agent.messaging.responder import BaseResponder +from acapy_agent.protocols.coordinate_mediation.v1_0.route_manager import RouteManager from acapy_agent.resolver.base import ResolutionMetadata, ResolutionResult, ResolverType from acapy_agent.resolver.did_resolver import DIDResolver from acapy_agent.tests import mock @@ -11,7 +12,7 @@ from acapy_agent.wallet.keys.manager import MultikeyManager from ...config.config import set_config -from ..manager import ControllerManager +from ..controller import ControllerManager from ..witness import WitnessManager from ..exceptions import ConfigurationError from ...protocols.states import WitnessingState @@ -132,6 +133,9 @@ async def asyncSetUp(self): self.profile.context.injector.bind_instance(DIDResolver, TEST_RESOLVER) self.profile.context.injector.bind_instance(EventBus, EventBus()) self.profile.context.injector.bind_instance(KeyTypes, KeyTypes()) + self.profile.context.injector.bind_instance( + RouteManager, mock.AsyncMock(RouteManager, autospec=True) + ) self.profile.settings.set_value( "plugin_config", {"webvh": {"server_url": f"https://{TEST_DOMAIN}"}} ) @@ -160,6 +164,39 @@ async def test_create_self_witness(self): # Create DID await self.controller.create(options={"namespace": TEST_NAMESPACE}) + @mock.patch("webvh.did.controller.decode_invitation") + @mock.patch("webvh.did.controller.InvitationMessage.from_url") + @mock.patch("webvh.did.controller.OutOfBandManager.receive_invitation") + @mock.patch("asyncio.sleep", new_callable=mock.AsyncMock) + async def test_connect_to_witness_uses_server_service( + self, _mock_sleep, mock_receive, mock_from_url, mock_decode + ): + desired_witness_id = f"did:key:{TEST_WITNESS_KEY}" + mock_decode.return_value = { + "goal": desired_witness_id, + "goal-code": "witness-service", + } + mock_from_url.return_value = mock.MagicMock() + self.controller.server_client.get_witness_services = mock.AsyncMock( + return_value=[ + { + "id": desired_witness_id, + "serviceEndpoint": "https://example.com?oob=mock", + } + ] + ) + connection_checker = mock.AsyncMock(side_effect=[None, mock.MagicMock()]) + with mock.patch.object( + self.controller, "_get_active_witness_connection", connection_checker + ): + result = await self.controller.connect_to_witness( + witness_id=desired_witness_id + ) + + self.controller.server_client.get_witness_services.assert_awaited_once() + mock_receive.assert_awaited_once() + assert result == desired_witness_id + @mock.patch("asyncio.sleep", mock.AsyncMock()) @mock.patch( "aiohttp.ClientSession.post", diff --git a/webvh/webvh/did/tests/test_witness_manager.py b/webvh/webvh/did/tests/test_witness_manager.py index 148237f81..bf1b00dc7 100644 --- a/webvh/webvh/did/tests/test_witness_manager.py +++ b/webvh/webvh/did/tests/test_witness_manager.py @@ -11,8 +11,9 @@ from acapy_agent.wallet.keys.manager import MultikeyManager from ..exceptions import ConfigurationError -from ..manager import ControllerManager +from ..controller import ControllerManager from ..witness import WitnessManager +from ...config.config import get_plugin_config from ...protocols.log_entry.record import PendingLogEntryRecord PENDING_DOCUMENT_TABLE_NAME = PendingLogEntryRecord().RECORD_TYPE @@ -50,10 +51,10 @@ async def asyncSetUp(self): ) async def test_witness_key_alias(self): - assert await self.witness.key_alias() + assert self.witness.key_alias async def test_witness_connection_alias(self): - assert await self.witness.connection_alias() + assert self.witness.connection_alias @mock.patch.object(WitnessManager, "_get_active_witness_connection") async def test_auto_witness_setup_as_witness( @@ -63,7 +64,7 @@ async def test_auto_witness_setup_as_witness( "plugin_config", {"webvh": {"witness": True, "server_url": SERVER_URL}}, ) - await self.controller.auto_witness_setup() + await self.controller.auto_setup() assert not mock_get_active_witness_connection.called async def test_auto_witness_setup_as_controller_no_server_url(self): @@ -72,7 +73,7 @@ async def test_auto_witness_setup_as_controller_no_server_url(self): {"webvh": {"witness": False}}, ) with self.assertRaises(ConfigurationError): - await self.controller.auto_witness_setup() + await self.controller.auto_setup() async def test_auto_witness_setup_as_controller_with_previous_connection(self): self.profile.settings.set_value( @@ -90,7 +91,7 @@ async def test_auto_witness_setup_as_controller_with_previous_connection(self): state="active", ) await record.save(session) - await self.controller.auto_witness_setup() + await self.controller.auto_setup() async def test_auto_witness_setup_as_controller_no_witness_invitation(self): self.profile.settings.set_value( @@ -102,7 +103,7 @@ async def test_auto_witness_setup_as_controller_no_witness_invitation(self): } }, ) - await self.controller.auto_witness_setup() + await self.controller.auto_setup() @mock.patch.object(OutOfBandManager, "receive_invitation") @mock.patch.object(asyncio, "sleep") @@ -121,7 +122,7 @@ async def test_auto_witness_setup_as_controller_no_active_connection(self, *_): self.profile.context.injector.bind_instance( RouteManager, mock.AsyncMock(RouteManager, autospec=True) ) - await self.controller.auto_witness_setup() + await self.controller.auto_setup() @mock.patch.object(OutOfBandManager, "receive_invitation") async def test_auto_witness_setup_as_controller_conn_becomes_active(self, *_): @@ -150,4 +151,62 @@ async def _create_connection(): await record.save(session) asyncio.create_task(_create_connection()) - await self.controller.auto_witness_setup() + await self.controller.auto_setup() + + async def test_witness_auto_setup_skips_when_not_configured(self): + self.profile.settings.set_value( + "plugin_config", + { + "webvh": { + "witness": False, + "server_url": SERVER_URL, + } + }, + ) + await self.witness.auto_setup() + config = await get_plugin_config(self.profile) + assert "witnesses" not in config + + async def test_witness_auto_setup_creates_key_and_updates_config(self): + profile = await create_test_profile( + { + "wallet.type": "askar-anoncreds", + "default_label": "TestWitness", + "default_endpoint": "https://example.com", + } + ) + profile.settings.set_value( + "plugin_config", + { + "webvh": { + "witness": True, + "server_url": SERVER_URL, + } + }, + ) + profile.context.injector.bind_instance(KeyTypes, KeyTypes()) + profile.context.injector.bind_instance( + RouteManager, mock.AsyncMock(RouteManager, autospec=True) + ) + witness = WitnessManager(profile) + with mock.patch.object( + WitnessManager, + "create_invitation", + new=mock.AsyncMock(return_value={"invitation_url": "https://example.com"}), + ): + await witness.auto_setup() + + config = await get_plugin_config(profile) + assert config.get("witnesses") + assert len(config["witnesses"]) == 1 + # Ensure the witness key exists and can be retrieved + assert await witness.get_witness_key() + + async def test_witness_configure_delegates_to_controller(self): + options = {"server_url": SERVER_URL, "auto_attest": True} + with mock.patch( + "webvh.did.tests.test_witness_manager.ControllerManager.configure", + new=mock.AsyncMock(), + ) as mock_configure: + await self.witness.configure(options) + mock_configure.assert_awaited_once() diff --git a/webvh/webvh/did/utils.py b/webvh/webvh/did/utils.py index 1f178b33a..abd134273 100644 --- a/webvh/webvh/did/utils.py +++ b/webvh/webvh/did/utils.py @@ -17,6 +17,7 @@ "nextKey": "@nextKey", "updateKey": "@updateKey", "witnessKey": "@witnessKey", + "innkeeperKey": "@innkeeper", } diff --git a/webvh/webvh/did/witness.py b/webvh/webvh/did/witness.py index f2f88d987..e811a5092 100644 --- a/webvh/webvh/did/witness.py +++ b/webvh/webvh/did/witness.py @@ -15,9 +15,9 @@ HSProto, ) -from ..config.config import get_plugin_config, get_server_domain +from ..config.config import get_plugin_config, set_config -from .exceptions import WitnessError +from .exceptions import WitnessError, ConfigurationError, OperationError from ..protocols.attested_resource.record import PendingAttestedResourceRecord from ..protocols.attested_resource.messages import ( WitnessRequest as AttestedResourceWitnessRequest, @@ -30,7 +30,7 @@ ) from ..protocols.states import WitnessingState from ..did.server_client import WebVHServerClient -from ..did.utils import find_key, add_proof +from ..did.utils import find_key, add_proof, create_key, url_to_domain, bind_key LOGGER = logging.getLogger(__name__) @@ -48,19 +48,155 @@ def __init__(self, profile: Profile): "proofPurpose": "assertionMethod", } - async def key_alias(self) -> str: - """Derive witness key alias.""" - domain = await get_server_domain(self.profile) + async def configure(self, config: dict) -> dict: + """Configure this agent as a witness. + + This creates the witness key, invitation, and updates the config. + Same logic as auto_setup but called from the configuration endpoint. + """ + if not config.get("witness", False): + return config + + config.setdefault("witnesses", []) + key_alias = self.key_alias + + # If witness_id is provided, try to use that key + if witness_id := config.get("witness_id", None): + witness_key = witness_id.split(":")[-1] # Extract key from did:key:xxx + await bind_key(self.profile, witness_key, key_alias) + else: + # Otherwise, find existing key or create new one + witness_key = await find_key(self.profile, key_alias) + if not witness_key: + LOGGER.info("Creating witness key for alias %s", key_alias) + witness_key = await create_key(self.profile, key_alias) + witness_id = f"did:key:{witness_key}" + + if witness_id not in config["witnesses"]: + config["witnesses"].append(witness_id) + await set_config(self.profile, config) + + invitation_record = await self.create_invitation( + alias=None, + label="Witness Service", + multi_use=True, + ) + invitation_url = None + if isinstance(invitation_record, dict): + invitation_url = invitation_record.get("invitation_url") + else: + invitation_url = getattr(invitation_record, "invitation_url", None) + + # Convert https:// URL to didcomm:// format + if invitation_url and invitation_url.startswith("http"): + from urllib.parse import urlparse, parse_qs + parsed = urlparse(invitation_url) + query = parse_qs(parsed.query) + if "oob" in query: + oob_param = query["oob"][0] + invitation_url = f"didcomm://?oob={oob_param}" + + # Store witness_id in config (but not invitation_url - it's generated on demand) + config["witness_id"] = witness_id + await set_config(self.profile, config) + + # Return config with invitation_url for API response (but don't persist it) + response_config = config.copy() + response_config["invitation_url"] = invitation_url + return response_config + + @property + def key_alias(self) -> str: + """Derive witness key alias from configured server URL.""" + config = self.profile.settings.get("plugin_config", {}).get("webvh", {}) or {} + server_url = config.get("server_url") + if not server_url: + raise ConfigurationError("No server url configured for witness.") + domain = url_to_domain(server_url) return f"webvh:{domain}@witnessKey" - async def connection_alias(self) -> str: + @property + def connection_alias(self) -> str: """Derive witness connection alias.""" - domain = await get_server_domain(self.profile) - return f"webvh:{domain}@witness" + # Reuse key_alias domain logic to keep behavior consistent + alias = self.key_alias + return alias.replace("@witnessKey", "@witness") + + async def auto_setup(self, config: dict | None = None): + """Automatically ensure the witness configuration is ready.""" + if config is None: + config = await get_plugin_config(self.profile) + + if not config.get("witness", False): + return + + config.setdefault("witnesses", []) + key_alias = self.key_alias + witness_key = await find_key(self.profile, key_alias) + if not witness_key: + LOGGER.info("Creating witness key for alias %s", key_alias) + witness_key = await create_key(self.profile, key_alias) + + witness_id = f"did:key:{witness_key}" + if witness_id not in config["witnesses"]: + config["witnesses"].append(witness_id) + await set_config(self.profile, config) + + invitation_record = await self.create_invitation( + alias=None, + label="Witness Service", + multi_use=True, + ) + invitation_url = None + if isinstance(invitation_record, dict): + invitation_url = invitation_record.get("invitation_url") + else: + invitation_url = getattr(invitation_record, "invitation_url", None) + + # Convert https:// URL to didcomm:// format + if invitation_url and invitation_url.startswith("http"): + from urllib.parse import urlparse, parse_qs + parsed = urlparse(invitation_url) + query = parse_qs(parsed.query) + if "oob" in query: + oob_param = query["oob"][0] + invitation_url = f"didcomm://?oob={oob_param}" + + print("\n" + "=" * 70) + print("✨" + " " * 20 + "WebVH Witness Ready!" + " " * 20 + "✨") + print("=" * 70) + print() + print(" 🔑 Witness ID:") + print(f" {witness_id}") + print() + print(" 📨 Invitation URL:") + print(f" {invitation_url or ''}") + print() + print("=" * 70) + print() + + async def create_invitation(self, alias=None, label=None, multi_use=False) -> str: + """Create a witness invitation.""" + witness_key = await self.get_witness_key() + try: + invi_rec = await OutOfBandManager(self.profile).create_invitation( + hs_protos=[ + HSProto.get("https://didcomm.org/didexchange/1.0"), + HSProto.get("https://didcomm.org/didexchange/1.1"), + ], + alias=alias, + my_label=label, + goal_code="witness-service", + goal=f"did:key:{witness_key}", + multi_use=multi_use, + ) + return invi_rec.serialize() + except OutOfBandManagerError as e: + raise WitnessError(e) async def _get_active_witness_connection(self) -> Optional[ConnRecord]: """Find active witness connection.""" - witness_alias = await self.connection_alias() + witness_alias = self.connection_alias async with self.profile.session() as session: connection_records = await ConnRecord.retrieve_by_alias( session, witness_alias @@ -77,7 +213,7 @@ async def _get_active_witness_connection(self) -> Optional[ConnRecord]: async def get_witness_key(self) -> str: """Return the witness key.""" - witness_alias = await self.key_alias() + witness_alias = self.key_alias witness_key = await find_key(self.profile, witness_alias) if not witness_key: raise WitnessError(f"Witness key [{witness_alias}] not found.") @@ -187,7 +323,7 @@ async def approve_log_entry( if not connection_id: # NOTE: will have to review this behavior when witness threshold is > 1 # is supported - from ..did.manager import ControllerManager + from ..did.controller import ControllerManager await ControllerManager(self.profile).finish_did_operation( log_entry, witness_signature @@ -241,22 +377,3 @@ async def approve_attested_resource( ) return {"status": "success", "message": "Witness successful."} - - async def create_invitation(self, alias=None, label=None, multi_use=False) -> str: - """Create a witness invitation.""" - witness_key = await self.get_witness_key() - try: - invi_rec = await OutOfBandManager(self.profile).create_invitation( - hs_protos=[ - HSProto.get("https://didcomm.org/didexchange/1.0"), - HSProto.get("https://didcomm.org/didexchange/1.1"), - ], - alias=alias, - my_label=label, - goal_code="witness-service", - goal=f"did:key:{witness_key}", - multi_use=multi_use, - ) - return invi_rec.serialize() - except OutOfBandManagerError as e: - raise WitnessError(e) diff --git a/webvh/webvh/protocols/attested_resource/handlers.py b/webvh/webvh/protocols/attested_resource/handlers.py index dd3ead048..e1415412a 100644 --- a/webvh/webvh/protocols/attested_resource/handlers.py +++ b/webvh/webvh/protocols/attested_resource/handlers.py @@ -8,7 +8,7 @@ from acapy_agent.messaging.responder import BaseResponder from ...did.utils import add_proof -from ...did.manager import ControllerManager +from ...did.controller import ControllerManager from ...did.witness import WitnessManager from ...config.config import get_plugin_config diff --git a/webvh/webvh/protocols/log_entry/handlers.py b/webvh/webvh/protocols/log_entry/handlers.py index 591337b52..55b0019b5 100644 --- a/webvh/webvh/protocols/log_entry/handlers.py +++ b/webvh/webvh/protocols/log_entry/handlers.py @@ -6,7 +6,7 @@ from acapy_agent.messaging.request_context import RequestContext from acapy_agent.messaging.responder import BaseResponder -from ...did.manager import ControllerManager +from ...did.controller import ControllerManager from ...did.witness import WitnessManager from ...config.config import get_plugin_config diff --git a/webvh/webvh/protocols/routes.py b/webvh/webvh/protocols/routes.py index 73f06d9c4..3e4e8a6a0 100644 --- a/webvh/webvh/protocols/routes.py +++ b/webvh/webvh/protocols/routes.py @@ -1,5 +1,7 @@ """DID Webvh protocol routes module.""" +import enum + from acapy_agent.admin.decorators.auth import tenant_authentication from acapy_agent.admin.request_context import AdminRequestContext from aiohttp import web @@ -9,24 +11,77 @@ from ..did.witness import WitnessManager from ..did.exceptions import WitnessError + +class WitnessRecordType(str, enum.Enum): + """Enum for witness request record types.""" + + ATTESTED_RESOURCE = "attested-resource" + LOG_ENTRY = "log-entry" + + RECORD_TYPES = { - "attested-resource": PendingAttestedResourceRecord(), - "log-entry": PendingLogEntryRecord(), + WitnessRecordType.ATTESTED_RESOURCE.value: PendingAttestedResourceRecord(), + WitnessRecordType.LOG_ENTRY.value: PendingLogEntryRecord(), } -@docs(tags=["did-webvh"], summary="Get all pending witness requests") +@docs( + tags=["did-webvh"], + summary="Get all pending witness requests", + parameters=[ + { + "in": "path", + "name": "record_type", + "required": True, + "schema": { + "type": "string", + "enum": [e.value for e in WitnessRecordType], + }, + "description": "Type of witness request record", + "example": WitnessRecordType.ATTESTED_RESOURCE.value, + } + ], +) @tenant_authentication async def get_pending_witness_requests(request: web.BaseRequest): """Get all pending witness requests.""" context: AdminRequestContext = request["context"] - record_type = request.match_info["record_type"] - PENDING_RECORDS = RECORD_TYPES.get(record_type, None) + record_type_str = request.match_info["record_type"] + try: + record_type = WitnessRecordType(record_type_str) + except ValueError: + raise WitnessError(f"Invalid record type: {record_type_str}. Must be one of: {[e.value for e in WitnessRecordType]}") + PENDING_RECORDS = RECORD_TYPES.get(record_type.value, None) + if PENDING_RECORDS is None: + raise WitnessError(f"Record type {record_type.value} not supported.") pending_witness_requests = await PENDING_RECORDS.get_pending_records(context.profile) return web.json_response({"results": pending_witness_requests}) -@docs(tags=["did-webvh"], summary="Approve a pending witness request") +@docs( + tags=["did-webvh"], + summary="Approve a pending witness request", + parameters=[ + { + "in": "path", + "name": "record_type", + "required": True, + "schema": { + "type": "string", + "enum": [e.value for e in WitnessRecordType], + }, + "description": "Type of witness request record", + "example": WitnessRecordType.ATTESTED_RESOURCE.value, + }, + { + "in": "path", + "name": "record_id", + "required": True, + "schema": {"type": "string"}, + "description": "ID of the pending witness request record", + }, + ], +) @tenant_authentication async def approve_pending_witness_request(request: web.BaseRequest): """Approve a pending attested resource.""" @@ -35,8 +90,14 @@ async def approve_pending_witness_request(request: web.BaseRequest): try: record_id = request.match_info["record_id"] - record_type = request.match_info["record_type"] - PENDING_RECORDS = RECORD_TYPES.get(record_type, None) + record_type_str = request.match_info["record_type"] + try: + record_type = WitnessRecordType(record_type_str) + except ValueError: + raise WitnessError(f"Invalid record type: {record_type_str}. Must be one of: {[e.value for e in WitnessRecordType]}") + PENDING_RECORDS = RECORD_TYPES.get(record_type.value, None) + if PENDING_RECORDS is None: + raise WitnessError(f"Record type {record_type.value} not supported.") record, connection_id = await PENDING_RECORDS.get_pending_record( context.profile, record_id @@ -44,11 +105,11 @@ async def approve_pending_witness_request(request: web.BaseRequest): if record is None: raise WitnessError("Failed to find pending document.") - if record_type == "attested-resource": + if record_type == WitnessRecordType.ATTESTED_RESOURCE: await manager.approve_attested_resource( record.get("record", None), connection_id, record_id ) - elif record_type == "log-entry": + elif record_type == WitnessRecordType.LOG_ENTRY: await manager.approve_log_entry( record.get("record", None), connection_id, record_id ) @@ -61,7 +122,30 @@ async def approve_pending_witness_request(request: web.BaseRequest): return web.json_response({"status": "error", "message": str(err)}) -@docs(tags=["did-webvh"], summary="Reject a pending witness request") +@docs( + tags=["did-webvh"], + summary="Reject a pending witness request", + parameters=[ + { + "in": "path", + "name": "record_type", + "required": True, + "schema": { + "type": "string", + "enum": [e.value for e in WitnessRecordType], + }, + "description": "Type of witness request record", + "example": WitnessRecordType.ATTESTED_RESOURCE.value, + }, + { + "in": "path", + "name": "record_id", + "required": True, + "schema": {"type": "string"}, + "description": "ID of the pending witness request record", + }, + ], +) @tenant_authentication async def reject_pending_witness_request(request: web.BaseRequest): """Reject a pending witness request.""" @@ -69,8 +153,14 @@ async def reject_pending_witness_request(request: web.BaseRequest): try: record_id = request.match_info["record_id"] - record_type = request.match_info["record_type"] - PENDING_RECORDS = RECORD_TYPES.get(record_type, None) + record_type_str = request.match_info["record_type"] + try: + record_type = WitnessRecordType(record_type_str) + except ValueError: + raise WitnessError(f"Invalid record type: {record_type_str}. Must be one of: {[e.value for e in WitnessRecordType]}") + PENDING_RECORDS = RECORD_TYPES.get(record_type.value, None) + if PENDING_RECORDS is None: + raise WitnessError(f"Record type {record_type.value} not supported.") return web.json_response( await PENDING_RECORDS.remove_pending_record(context.profile, record_id) ) diff --git a/webvh/webvh/routes.py b/webvh/webvh/routes.py index 76e2bf100..edabd1705 100644 --- a/webvh/webvh/routes.py +++ b/webvh/webvh/routes.py @@ -14,8 +14,8 @@ from aiohttp_apispec import docs, querystring_schema, request_schema, response_schema from marshmallow.exceptions import ValidationError -from .config.config import get_plugin_config -from .did.manager import ControllerManager +from .config.config import get_global_plugin_config, get_plugin_config, set_config +from .did.controller import ControllerManager from .did.exceptions import ( ConfigurationError, DidCreationError, @@ -28,7 +28,6 @@ WebvhAddVMSchema, WebvhCreateSchema, WebvhUpdateSchema, - WebvhCreateWitnessInvitationSchema, WebvhDeactivateSchema, WebvhSCIDQueryStringSchema, WebvhUpdateWhoisSchema, @@ -59,29 +58,36 @@ async def configure(request: web.BaseRequest): request_json = await request.json() try: - return web.json_response(await ControllerManager(profile).configure(request_json)) + options = request_json + config = await get_plugin_config(profile) + + config["server_url"] = options.get( + "server_url", config.get("server_url") + ).rstrip( "/") + + if not config.get("server_url"): + raise OperationError("No server url configured.") + + config["scids"] = config.get("scids", {}) + config["witnesses"] = config.get("witnesses", []) + config["endorsement"] = options.get("endorsement", False) + config["auto_attest"] = options.get("auto_attest", False) + config["parameter_options"] = options.get("parameter_options", {}) + config["witness_id"] = options.get("witness_id", config.get("witness_id")) + + await set_config(profile, config) + + + config["witness"] = options.get("witness", False) + if config["witness"]: + return web.json_response(await WitnessManager(profile).configure(config)) + else: + return web.json_response(await ControllerManager(profile).configure(config)) except (ConfigurationError, OperationError) as err: return web.json_response({"status": "error", "message": str(err)}) -@docs(tags=["did-webvh"], summary="Create a witness invitation") -@request_schema(WebvhCreateWitnessInvitationSchema) -@tenant_authentication -async def witness_create_invite(request: web.BaseRequest): - """Create a witness invitation.""" - context: AdminRequestContext = request["context"] - request_json = await request.json() - try: - return web.json_response( - await WitnessManager(context.profile).create_invitation( - request_json.get("alias"), - request_json.get("label"), - request_json.get("multi"), - ) - ) - except (StorageNotFoundError, ValidationError, WitnessError) as e: - raise web.HTTPBadRequest(reason=e.roll_up) @docs(tags=["did-webvh"], summary="Create a did:webvh") @@ -213,9 +219,29 @@ def register_events(event_bus: EventBus): async def on_startup_event(profile: Profile, event: Event): - """Handle the witness setup.""" - if not profile.settings.get("multitenant.enabled"): - await ControllerManager(profile).auto_witness_setup() + """Handle the plugin startup setup.""" + config = get_global_plugin_config(profile) + + if profile.settings.get("multitenant.enabled"): + return + + if not config.get("auto_config", False): + return + + # Remove auto_config from config before passing to setup methods + # so it doesn't get persisted to stored config + # All other config values (including witness_id) are preserved + config_without_auto = {k: v for k, v in config.items() if k != "auto_config"} + + if config.get("witness", False): + await WitnessManager(profile).auto_setup(config_without_auto) + else: + await ControllerManager(profile).auto_setup(config_without_auto) + # Save witness_id to stored config if it's in the global config + if "witness_id" in config_without_auto: + stored_config = await get_plugin_config(profile) + stored_config["witness_id"] = config_without_auto["witness_id"] + await set_config(profile, stored_config) async def register(app: web.Application): @@ -236,7 +262,6 @@ async def register(app: web.Application): # delete_verification_method_request, # ), web.post("/did/webvh/whois", update_whois), - web.post("/did/webvh/witness-invitation", witness_create_invite), web.get( "/did/webvh/witness-requests/{record_type}", get_pending_witness_requests, diff --git a/webvh/webvh/tests/test_routes.py b/webvh/webvh/tests/test_routes.py index 131e6ab81..de4c5122b 100644 --- a/webvh/webvh/tests/test_routes.py +++ b/webvh/webvh/tests/test_routes.py @@ -10,6 +10,7 @@ get_config, configure, create, + on_startup_event, ) TEST_SERVER_URL = "https://sandbox.bcvh.vonx.io" @@ -69,6 +70,7 @@ async def test_configure_witness(self): "server_url": TEST_SERVER_URL, "witness": True, "auto_attest": True, + "auto_config": False, } ), __getitem__=lambda _, k: self.request_dict[k], @@ -91,6 +93,7 @@ async def test_configure_controller(self, *_): "server_url": TEST_SERVER_URL, "witness": False, "witness_invitation": TEST_WITNESS_INVITATION_URL, + "auto_config": True, } ), __getitem__=lambda _, k: self.request_dict[k], @@ -113,3 +116,51 @@ async def test_create(self): ) await create(self.request) + + @mock.patch("webvh.routes.WitnessManager.auto_setup", new_callable=mock.AsyncMock) + @mock.patch("webvh.routes.ControllerManager.auto_setup", new_callable=mock.AsyncMock) + async def test_on_startup_event_skips_when_auto_setup_disabled( + self, mock_controller_auto, mock_witness_auto + ): + self.profile.settings.set_value("multitenant.enabled", False) + self.profile.settings.set_value( + "plugin_config", + {"webvh": {"server_url": TEST_SERVER_URL, "auto_config": False}}, + ) + await on_startup_event(self.profile, mock.MagicMock()) + assert mock_controller_auto.await_count == 0 + assert mock_witness_auto.await_count == 0 + + @mock.patch("webvh.routes.WitnessManager.auto_setup", new_callable=mock.AsyncMock) + @mock.patch("webvh.routes.ControllerManager.auto_setup", new_callable=mock.AsyncMock) + async def test_on_startup_event_runs_controller_auto_setup( + self, mock_controller_auto, mock_witness_auto + ): + self.profile.settings.set_value("multitenant.enabled", False) + self.profile.settings.set_value( + "plugin_config", + {"webvh": {"server_url": TEST_SERVER_URL, "auto_config": True}}, + ) + await on_startup_event(self.profile, mock.MagicMock()) + mock_controller_auto.assert_awaited_once() + assert mock_witness_auto.await_count == 0 + + @mock.patch("webvh.routes.WitnessManager.auto_setup", new_callable=mock.AsyncMock) + @mock.patch("webvh.routes.ControllerManager.auto_setup", new_callable=mock.AsyncMock) + async def test_on_startup_event_runs_witness_auto_setup( + self, mock_controller_auto, mock_witness_auto + ): + self.profile.settings.set_value("multitenant.enabled", False) + self.profile.settings.set_value( + "plugin_config", + { + "webvh": { + "server_url": TEST_SERVER_URL, + "witness": True, + "auto_config": True, + } + }, + ) + await on_startup_event(self.profile, mock.MagicMock()) + assert mock_controller_auto.await_count == 0 + mock_witness_auto.assert_awaited_once() From 37388f5cb52c6fff589b3ef12aa39e6526bf9ec0 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Thu, 20 Nov 2025 21:26:35 -0500 Subject: [PATCH 02/21] log witness config Signed-off-by: Patrick St-Louis --- webvh/webvh/did/witness.py | 5 +++++ webvh/webvh/protocols/routes.py | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/webvh/webvh/did/witness.py b/webvh/webvh/did/witness.py index e811a5092..e4ddcfc80 100644 --- a/webvh/webvh/did/witness.py +++ b/webvh/webvh/did/witness.py @@ -174,6 +174,11 @@ async def auto_setup(self, config: dict | None = None): print() print("=" * 70) print() + + # Also log the configuration details + LOGGER.info( + f"WebVH Witness configured - witness_id: {witness_id}, invitation_url: {invitation_url or ''}" + ) async def create_invitation(self, alias=None, label=None, multi_use=False) -> str: """Create a witness invitation.""" diff --git a/webvh/webvh/protocols/routes.py b/webvh/webvh/protocols/routes.py index 3e4e8a6a0..5c0746bf7 100644 --- a/webvh/webvh/protocols/routes.py +++ b/webvh/webvh/protocols/routes.py @@ -1,6 +1,7 @@ """DID Webvh protocol routes module.""" import enum +import logging from acapy_agent.admin.decorators.auth import tenant_authentication from acapy_agent.admin.request_context import AdminRequestContext @@ -11,6 +12,8 @@ from ..did.witness import WitnessManager from ..did.exceptions import WitnessError +LOGGER = logging.getLogger(__name__) + class WitnessRecordType(str, enum.Enum): """Enum for witness request record types.""" @@ -116,6 +119,9 @@ async def approve_pending_witness_request(request: web.BaseRequest): await PENDING_RECORDS.remove_pending_record(context.profile, record_id) + LOGGER.info( + f"Witness successful for {record_type.value} record {record_id}" + ) return web.json_response({"status": "success", "message": "Witness successful."}) except WitnessError as err: From 5b07a3f4eff7b3acb1356eae9c60a50b2097bd31 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Thu, 20 Nov 2025 21:39:28 -0500 Subject: [PATCH 03/21] fix auto setup Signed-off-by: Patrick St-Louis --- webvh/webvh/did/witness.py | 17 ++++++++++++++--- webvh/webvh/routes.py | 7 +++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/webvh/webvh/did/witness.py b/webvh/webvh/did/witness.py index e4ddcfc80..27de3e853 100644 --- a/webvh/webvh/did/witness.py +++ b/webvh/webvh/did/witness.py @@ -76,10 +76,12 @@ async def configure(self, config: dict) -> dict: config["witnesses"].append(witness_id) await set_config(self.profile, config) + # Use the witness_key we already have instead of calling get_witness_key() invitation_record = await self.create_invitation( alias=None, label="Witness Service", multi_use=True, + witness_key=witness_key, ) invitation_url = None if isinstance(invitation_record, dict): @@ -128,7 +130,10 @@ async def auto_setup(self, config: dict | None = None): config = await get_plugin_config(self.profile) if not config.get("witness", False): + LOGGER.debug("Skipping witness auto_setup - witness not configured") return + + LOGGER.info("Starting witness auto_setup") config.setdefault("witnesses", []) key_alias = self.key_alias @@ -142,10 +147,12 @@ async def auto_setup(self, config: dict | None = None): config["witnesses"].append(witness_id) await set_config(self.profile, config) + # Use the witness_key we already have instead of calling get_witness_key() invitation_record = await self.create_invitation( alias=None, label="Witness Service", multi_use=True, + witness_key=witness_key, ) invitation_url = None if isinstance(invitation_record, dict): @@ -176,13 +183,17 @@ async def auto_setup(self, config: dict | None = None): print() # Also log the configuration details + invitation_display = invitation_url if invitation_url else "" LOGGER.info( - f"WebVH Witness configured - witness_id: {witness_id}, invitation_url: {invitation_url or ''}" + "WebVH Witness configured - witness_id: %s, invitation_url: %s", + witness_id, + invitation_display ) - async def create_invitation(self, alias=None, label=None, multi_use=False) -> str: + async def create_invitation(self, alias=None, label=None, multi_use=False, witness_key=None) -> str: """Create a witness invitation.""" - witness_key = await self.get_witness_key() + if witness_key is None: + witness_key = await self.get_witness_key() try: invi_rec = await OutOfBandManager(self.profile).create_invitation( hs_protos=[ diff --git a/webvh/webvh/routes.py b/webvh/webvh/routes.py index edabd1705..7b2348a96 100644 --- a/webvh/webvh/routes.py +++ b/webvh/webvh/routes.py @@ -215,18 +215,25 @@ async def update_whois(request: web.BaseRequest): def register_events(event_bus: EventBus): """Register to the acapy startup event.""" + LOGGER.info("Registering WebVH startup event handler") event_bus.subscribe(STARTUP_EVENT_PATTERN, on_startup_event) async def on_startup_event(profile: Profile, event: Event): """Handle the plugin startup setup.""" + LOGGER.info("WebVH startup event received") config = get_global_plugin_config(profile) + LOGGER.info("WebVH global config: %s", config) if profile.settings.get("multitenant.enabled"): + LOGGER.info("Skipping WebVH auto_config - multitenant enabled") return if not config.get("auto_config", False): + LOGGER.info("Skipping WebVH auto_config - auto_config not enabled in config") return + + LOGGER.info("WebVH auto_config enabled, proceeding with setup") # Remove auto_config from config before passing to setup methods # so it doesn't get persisted to stored config From 17b954e9d052926317e385169e81eb3a9a0805f7 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Thu, 20 Nov 2025 21:52:24 -0500 Subject: [PATCH 04/21] add logging Signed-off-by: Patrick St-Louis --- webvh/webvh/did/witness.py | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/webvh/webvh/did/witness.py b/webvh/webvh/did/witness.py index 27de3e853..318f2639b 100644 --- a/webvh/webvh/did/witness.py +++ b/webvh/webvh/did/witness.py @@ -169,26 +169,25 @@ async def auto_setup(self, config: dict | None = None): oob_param = query["oob"][0] invitation_url = f"didcomm://?oob={oob_param}" - print("\n" + "=" * 70) - print("✨" + " " * 20 + "WebVH Witness Ready!" + " " * 20 + "✨") - print("=" * 70) - print() - print(" 🔑 Witness ID:") - print(f" {witness_id}") - print() - print(" 📨 Invitation URL:") - print(f" {invitation_url or ''}") - print() - print("=" * 70) - print() - - # Also log the configuration details + # Format the witness configuration message invitation_display = invitation_url if invitation_url else "" - LOGGER.info( - "WebVH Witness configured - witness_id: %s, invitation_url: %s", - witness_id, - invitation_display - ) + message = f""" +{'=' * 70} +✨{' ' * 20}WebVH Witness Ready!{' ' * 20}✨ +{'=' * 70} + + 🔑 Witness ID: + {witness_id} + + 📨 Invitation URL: + {invitation_display} + +{'=' * 70} +""" + + # Log and print the same message + LOGGER.info(message) + print(message, end="") async def create_invitation(self, alias=None, label=None, multi_use=False, witness_key=None) -> str: """Create a witness invitation.""" From 051d9f6e7306bf303b603ed3729f9a8810faafdb Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Thu, 20 Nov 2025 21:55:37 -0500 Subject: [PATCH 05/21] change log level Signed-off-by: Patrick St-Louis --- webvh/webvh/did/witness.py | 13 ++++++++++--- webvh/webvh/routes.py | 18 ++++++++++++------ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/webvh/webvh/did/witness.py b/webvh/webvh/did/witness.py index 318f2639b..d37656b21 100644 --- a/webvh/webvh/did/witness.py +++ b/webvh/webvh/did/witness.py @@ -133,14 +133,18 @@ async def auto_setup(self, config: dict | None = None): LOGGER.debug("Skipping witness auto_setup - witness not configured") return - LOGGER.info("Starting witness auto_setup") + LOGGER.warning("=" * 70) + LOGGER.warning("Starting witness auto_setup") + LOGGER.warning("Witness auto_setup: config.witness = %s", config.get("witness")) config.setdefault("witnesses", []) key_alias = self.key_alias + LOGGER.warning("Witness auto_setup: key_alias = %s", key_alias) witness_key = await find_key(self.profile, key_alias) if not witness_key: - LOGGER.info("Creating witness key for alias %s", key_alias) + LOGGER.warning("Creating witness key for alias %s", key_alias) witness_key = await create_key(self.profile, key_alias) + LOGGER.warning("Created witness key: %s", witness_key[:20] + "..." if witness_key else "None") witness_id = f"did:key:{witness_key}" if witness_id not in config["witnesses"]: @@ -186,7 +190,10 @@ async def auto_setup(self, config: dict | None = None): """ # Log and print the same message - LOGGER.info(message) + # Use WARNING level to ensure visibility in Docker logs + # Use multiple LOGGER.warning calls to ensure each line is logged separately + for line in message.strip().split('\n'): + LOGGER.warning(line) print(message, end="") async def create_invitation(self, alias=None, label=None, multi_use=False, witness_key=None) -> str: diff --git a/webvh/webvh/routes.py b/webvh/webvh/routes.py index 7b2348a96..f7995218e 100644 --- a/webvh/webvh/routes.py +++ b/webvh/webvh/routes.py @@ -215,25 +215,31 @@ async def update_whois(request: web.BaseRequest): def register_events(event_bus: EventBus): """Register to the acapy startup event.""" - LOGGER.info("Registering WebVH startup event handler") + LOGGER.warning("=" * 70) + LOGGER.warning("Registering WebVH startup event handler") + LOGGER.warning("=" * 70) event_bus.subscribe(STARTUP_EVENT_PATTERN, on_startup_event) + LOGGER.warning("WebVH startup event handler registered successfully") async def on_startup_event(profile: Profile, event: Event): """Handle the plugin startup setup.""" - LOGGER.info("WebVH startup event received") + LOGGER.warning("=" * 70) + LOGGER.warning("WebVH startup event received") + LOGGER.warning("=" * 70) config = get_global_plugin_config(profile) - LOGGER.info("WebVH global config: %s", config) + LOGGER.warning("WebVH global config: %s", config) if profile.settings.get("multitenant.enabled"): - LOGGER.info("Skipping WebVH auto_config - multitenant enabled") + LOGGER.warning("Skipping WebVH auto_config - multitenant enabled") return if not config.get("auto_config", False): - LOGGER.info("Skipping WebVH auto_config - auto_config not enabled in config") + LOGGER.warning("Skipping WebVH auto_config - auto_config not enabled in config") return - LOGGER.info("WebVH auto_config enabled, proceeding with setup") + LOGGER.warning("WebVH auto_config enabled, proceeding with setup") + LOGGER.warning("Witness mode: %s", config.get("witness", False)) # Remove auto_config from config before passing to setup methods # so it doesn't get persisted to stored config From 4d614bb4e6fac23c086314e475dbd087ad66a6f9 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 24 Nov 2025 19:32:51 -0500 Subject: [PATCH 06/21] first draft, updated auto_config options and added keychain Signed-off-by: Patrick St-Louis --- webvh/README.md | 112 +++- webvh/webvh/__init__.py | 3 + webvh/webvh/config/config.py | 7 +- webvh/webvh/did/connection.py | 327 ++++++++++ webvh/webvh/did/controller.py | 574 +++++------------- webvh/webvh/did/key_chain.py | 259 ++++++++ webvh/webvh/did/parameters.py | 205 +++++++ webvh/webvh/did/server_client.py | 41 +- .../did/tests/test_controller_manager.py | 90 ++- webvh/webvh/did/tests/test_witness_manager.py | 72 ++- webvh/webvh/did/utils.py | 225 ++++++- webvh/webvh/did/witness.py | 269 +++----- .../protocols/attested_resource/handlers.py | 4 +- webvh/webvh/protocols/events.py | 143 +++++ webvh/webvh/protocols/log_entry/handlers.py | 4 +- webvh/webvh/protocols/routes.py | 19 +- webvh/webvh/protocols/states.py | 179 +++++- webvh/webvh/routes.py | 110 +++- webvh/webvh/tests/test_routes.py | 16 +- 19 files changed, 1893 insertions(+), 766 deletions(-) create mode 100644 webvh/webvh/did/connection.py create mode 100644 webvh/webvh/did/key_chain.py create mode 100644 webvh/webvh/did/parameters.py create mode 100644 webvh/webvh/protocols/events.py diff --git a/webvh/README.md b/webvh/README.md index 6961e2ce0..6b14fd63b 100644 --- a/webvh/README.md +++ b/webvh/README.md @@ -25,6 +25,78 @@ graph TD; - `controller` - The controller is the agent or tenant that is responsible for the did. It can create and update webvh dids and anoncreds objects and interact with other agents. The controller does not have the ability to sign with the key that is verified by the server, and needs to get a proof from an agent which does have the correct key(s). - `witness` - The witness is the agent or tenant that is responsible for signing requests from the controller. The witness has the ability to sign with the key that is verified by the server, and can provide a proof to the controller that it is a trusted source. It can also create dids and anoncreds objects itself and act as a controller by self signing the upload requests with the server. +### Internal Architecture + +The plugin is organized into several key components that handle different aspects of DID management: + +#### KeyChainManager + +The `KeyChainManager` is the central component for all cryptographic key operations in the plugin. It provides a unified interface for managing keys associated with DIDs and handles the lifecycle of keys used for signing, updating, and rotating DIDs. + +**Key Operations:** + +- **Key Creation**: Creates new Ed25519 keys using `create_key(kid)` where `kid` is an optional key ID to bind the key to +- **Key Lookup**: Finds keys by their key ID (`find_key(kid)`) or multikey (`find_multikey(multikey)`) +- **Key Binding**: Associates keys with specific purposes using `bind_key(multikey, kid)` +- **Key Unbinding**: Removes associations between keys and key IDs using `unbind_key(multikey, kid)` + +**DID-Specific Key Management:** + +The KeyChainManager provides convenience methods for common DID key types: + +- **`update_key(did)`** - Retrieves the update key for a DID (used to sign log entries) +- **`signing_key(did)`** - Retrieves the signing/authentication key for a DID +- **`next_key(did)`** - Retrieves the next key for prerotation (used for key rotation) + +**Key Binding Patterns:** + +Keys are bound to DIDs using a consistent naming pattern: +- Update key: `{did}#updateKey` +- Signing key: `{did}#signingKey` (also bound as `{did}#{multikey}` for direct lookup) +- Next key: `{did}#nextKey` +- Verification methods: `{did}#{key_id}` + +**Key Operations:** + +- **`migrate_key(from_did, to_did, key_type)`** - Migrates a single key from a placeholder DID to the final DID (useful during DID creation) +- **`rotate_update_key(did)`** - Implements prerotation by: + 1. Unbinding the current update key + 2. Promoting the next key to become the new update key + 3. Creating and binding a new next key + 4. Returning the new update key and next key hash +- **`bind_verification_method(did, key_id, multikey)`** - Binds a verification method key to a DID +- **`unbind_verification_method(did, key_id)`** - Removes a verification method key binding +- **`key_hash(key)`** - Calculates the SHA-256 multihash of a key (used for nextKeyHash in prerotation) + +**Example Usage:** + +```python +# Create a new key +multikey = await key_chain.create_key() + +# Bind keys to a DID +await key_chain.bind_key(signing_key, "did:webvh:...#signingKey") +await key_chain.bind_key(signing_key, "did:webvh:...#{signing_key}") +await key_chain.bind_key(update_key, "did:webvh:...#updateKey") +await key_chain.bind_key(next_key, "did:webvh:...#nextKey") + +# Retrieve keys for operations +update_key = await key_chain.update_key(did) +signing_key = await key_chain.signing_key(did) + +# Rotate keys (prerotation pattern) +new_update_key, next_key_hash = await key_chain.rotate_update_key(did) +``` + +#### Other Core Components + +- **`ControllerManager`** - Main entry point for DID operations (create, update, deactivate) +- **`WitnessManager`** - Handles witness operations (signing log entries and attested resources) +- **`WitnessConnectionManager`** - Manages connections between controllers and witnesses +- **`ParameterResolver`** - Resolves and applies default, policy, and user-provided DID parameters +- **`WitnessingStateHandler`** - Manages state transitions for witnessing operations (PENDING, ATTESTED, SUCCESS, FINISHED) +- **`WitnessEventManager`** - Handles event firing for witness-related operations + #### Server The server used by this plugin is located at [DIF](https://github.com/decentralized-identity/didwebvh-server-py). For a witness signature to be approved, the witness key needs to be registered as a `known-witness` by the server administration. It will only allow dids to be created where the original request is signed by a `known-witness`. After the initial did is created the update key from the controller is obtained from the original request and stored by the server. This update key must be used in the initial log entry. @@ -38,8 +110,8 @@ sequenceDiagram participant Witness Tenant Controller Tenant->>WebVH Server: Request a DID location. WebVH Server->>Controller Tenant: Provide a Data Integrity Proof configuration. - Controller Tenant->>Controller Tenant: Create new update key. - Controller Tenant->>Controller Tenant: Create new verification method. + Controller Tenant->>Controller Tenant: Create new update key (via KeyChainManager). + Controller Tenant->>Controller Tenant: Create new verification method (via KeyChainManager). Controller Tenant->>Controller Tenant: Create DID document and sign with update key. Controller Tenant->>Witness Tenant: Request registration signature. Witness Tenant->>Witness Tenant: Verify and sign DID registration. @@ -47,11 +119,25 @@ sequenceDiagram Controller Tenant->>WebVH Server: Send approved DID registration. WebVH Server->>WebVH Server: Verify approved DID registration. Controller Tenant->>Controller Tenant: Generate preliminary DID log entry. - Controller Tenant->>Controller Tenant: Transform and sign initial DID log entry. + Controller Tenant->>Controller Tenant: Transform and sign initial DID log entry (using update key from KeyChainManager). Controller Tenant->>WebVH Server: Send initial DID log entry. WebVH Server->>WebVH Server: Verify initial DID log entry & publish DID. ``` +**Key Management During DID Creation:** + +1. **Key Creation**: The controller uses `KeyChainManager.create_key()` to generate new Ed25519 keys for: + - Signing/authentication key (used in verification methods) + - Update key (used to sign log entries) + - Next key (for prerotation, if enabled) + +2. **Key Binding**: Keys are bound to the placeholder DID using `KeyChainManager.bind_key()`: + - Signing key is bound as `{did}#signingKey` and `{did}#{multikey}` + - Update key is bound as `{did}#updateKey` + - Next key is bound as `{did}#nextKey` (if prerotation is enabled) + +3. **Key Migration**: After the DID is finalized, keys are migrated from the placeholder DID to the final DID using `KeyChainManager.migrate_key()` for each key type to ensure all key bindings reference the correct DID. + ## Configuration The first step is to configure the plugin for your wallet / subwallet. You can configure it as a Witness or a Controller. @@ -301,6 +387,20 @@ WHOIS data is published as a dedicated attested Verifiable Presentation (VP) lin When updating a DID, you will usually modify the webvh parameters, add/remove a verification method or edit the services. +#### Key Rotation + +The plugin supports key rotation using the prerotation pattern. When prerotation is enabled: + +1. A `nextKey` is created and bound to the DID during initial creation +2. The `nextKeyHash` is included in the DID parameters +3. When rotating keys, `KeyChainManager.rotate_update_key()` is called: + - The current `updateKey` is unbound + - The `nextKey` is promoted to become the new `updateKey` + - A new `nextKey` is created and bound + - The new `nextKeyHash` is included in the update log entry + +This ensures seamless key rotation without losing control of the DID. + #### Updating the verification methods `POST /did/webvh/controller/verification-methods` @@ -316,5 +416,7 @@ When updating a DID, you will usually modify the webvh parameters, add/remove a - `id` - An optional key id to use, defaults to a public multikey or jwk thumbprint depending on the type. - `type` - The key representation in the DID document, can be `Multikey` or `JsonWebKey`. - - `multikey` - Optionally use an existing local keypair. Otherwise create a new one. - - `relationships` - Add the relationships for this key. Refer to the DID core specification for more information about relationships. \ No newline at end of file + - `multikey` - Optionally use an existing local keypair. Otherwise create a new one via `KeyChainManager.create_key()`. + - `relationships` - Add the relationships for this key. Refer to the DID core specification for more information about relationships. + +When adding a verification method, the key is bound to the DID using `KeyChainManager.bind_verification_method()`, which creates a binding like `{did}#{key_id}`. When removing a verification method, `KeyChainManager.unbind_verification_method()` removes the key binding. \ No newline at end of file diff --git a/webvh/webvh/__init__.py b/webvh/webvh/__init__.py index 9e7845e5c..6588f56a1 100644 --- a/webvh/webvh/__init__.py +++ b/webvh/webvh/__init__.py @@ -18,6 +18,9 @@ async def setup(context: InjectionContext): """Setup.""" LOGGER.info("webvh plugin setup...") + LOGGER.warning("=" * 70) + LOGGER.warning("WebVH Plugin: Starting setup") + LOGGER.warning("=" * 70) # AnonCreds Registry anoncreds_registry = context.inject_or(AnonCredsRegistry) diff --git a/webvh/webvh/config/config.py b/webvh/webvh/config/config.py index 425af0fd7..738398d52 100644 --- a/webvh/webvh/config/config.py +++ b/webvh/webvh/config/config.py @@ -3,8 +3,6 @@ import copy import json -from operator import itemgetter - from acapy_agent.core.profile import Profile from acapy_agent.storage.base import BaseStorage from acapy_agent.storage.error import StorageNotFoundError @@ -137,7 +135,10 @@ async def use_strict_ssl(profile: Profile): async def add_scid_mapping(profile: Profile, did: str): """Add a scid mapping.""" - scid = itemgetter(2)(did.split(":")) + from ..did.utils import parse_webvh + + parsed = parse_webvh(did) + scid = parsed.scid async with profile.session() as session: storage = session.inject(BaseStorage) stored_config_record = await storage.get_record( diff --git a/webvh/webvh/did/connection.py b/webvh/webvh/did/connection.py new file mode 100644 index 000000000..b0d169cb5 --- /dev/null +++ b/webvh/webvh/did/connection.py @@ -0,0 +1,327 @@ +"""WebVH Connection Manager for handling WebVH connection lifecycle.""" + +import asyncio +import logging +from typing import Optional + +from acapy_agent.connections.models.conn_record import ConnRecord +from acapy_agent.core.profile import Profile +from acapy_agent.messaging.models.base import BaseModelError +from acapy_agent.protocols.out_of_band.v1_0.manager import ( + OutOfBandManager, + OutOfBandManagerError, +) +from acapy_agent.protocols.out_of_band.v1_0.messages.invitation import HSProto + +from ..config.config import ( + get_plugin_config, + get_server_domain, + get_server_url, + is_controller, + set_config, +) +from .exceptions import ConfigurationError, OperationError, WitnessError +from .server_client import WebVHServerClient +from .utils import create_alias, url_to_domain + +LOGGER = logging.getLogger(__name__) + +CONNECTION_WAIT_RETRIES = 5 +CONNECTION_WAIT_INTERVAL_SECONDS = 1 + + +class WebVHConnectionManager: + """Manages WebVH connection lifecycle for controllers.""" + + def __init__(self, profile: Profile): + """Initialize the WebVHConnectionManager with a profile.""" + self.profile = profile + + def _get_connection_alias(self) -> str: + """Get the witness connection alias for this controller. + + Returns: + The connection alias string (e.g., "webvh:example.com@witness") + """ + # This will be called after server_url is configured, + # so we can get it synchronously + # For async operations, use get_active_connection() which handles this properly + try: + # Try to get server_url from settings synchronously + config = self.profile.settings.get("plugin_config", {}).get("webvh", {}) + server_url = config.get("server_url") + if server_url: + domain = url_to_domain(server_url) + return create_alias(domain, "witnessConnection") + except Exception: + pass + # Fallback - will be resolved async in get_active_connection + return "webvh:unknown@witness" + + async def get_active_connection( + self, auto_connect: bool = False + ) -> Optional[ConnRecord]: + """Get an active witness connection if one exists. + + Args: + auto_connect: If True, attempt to establish connection + if not already connected + + Returns: + An active ConnRecord if found, None otherwise + """ + try: + server_url = await get_server_url(self.profile) + except ConfigurationError: + # No server_url configured yet, so no active connection possible + return None + + witness_alias = create_alias(url_to_domain(server_url), "witnessConnection") + async with self.profile.session() as session: + connection_records = await ConnRecord.retrieve_by_alias( + session, witness_alias + ) + + active_connections = [ + conn for conn in connection_records if conn.state == "active" + ] + + if len(active_connections) > 0: + return active_connections[0] + + # Attempt to connect if requested and no active connection found + if auto_connect: + try: + await self.connect() + # Try again after connecting + async with self.profile.session() as session: + connection_records = await ConnRecord.retrieve_by_alias( + session, witness_alias + ) + active_connections = [ + conn for conn in connection_records if conn.state == "active" + ] + if len(active_connections) > 0: + return active_connections[0] + except (ConfigurationError, OperationError) as err: + LOGGER.debug("Failed to auto-connect to witness: %s", err) + + return None + + async def connect( + self, + witness_id: str = None, + wait_for_connection: bool = True, + max_retries: int = CONNECTION_WAIT_RETRIES, + retry_interval: float = CONNECTION_WAIT_INTERVAL_SECONDS, + ) -> str: + """Connect to a witness service. + + This method: + 1. Gets witness_id from config if not provided + 2. Fetches the witness invitation from the server + 3. Validates the invitation + 4. Checks if already connected + 5. Receives the invitation and establishes connection + 6. Optionally waits for the connection to become active + + Args: + witness_id: Optional DID of the witness service (e.g., "did:key:...") + If not provided, will be fetched from config + wait_for_connection: If True, wait for connection to become active + max_retries: Maximum number of retries when waiting for connection + retry_interval: Seconds to wait between retries + + Returns: + The witness_id that was connected to + + Raises: + OperationError: If witness is not found, invitation is invalid, + or connection fails + ConfigurationError: If witness_id is not configured + """ + # Get witness_id from config if not provided + if witness_id is None: + config = await get_plugin_config(self.profile) + witness_id = config.get("witness_id") + if not witness_id: + raise ConfigurationError( + "No witness_id configured. Cannot connect to witness." + ) + + # Fetch invitation from server + server_client = WebVHServerClient(self.profile) + invitation = await server_client.get_witness_invitation(witness_id) + + if not invitation: + raise OperationError(f"Witness {witness_id} not listed by server document.") + + # Validate invitation + if invitation.get("goal-code", None) != "witness-service": + raise OperationError("Missing invitation goal-code and witness did.") + + if invitation.get("goal", None) != witness_id: + raise OperationError("Wrong invitation goal must match witness id.") + + # Check if already connected + if await self.get_active_connection(): + LOGGER.info("Connected to witness from previous connection.") + return witness_id + + # Receive invitation and establish connection + try: + server_domain = await get_server_domain(self.profile) + witness_alias = f"webvh:{server_domain}@witness" + await OutOfBandManager(self.profile).receive_invitation( + invitation=invitation, + auto_accept=True, + alias=witness_alias, + ) + except BaseModelError as err: + raise OperationError(f"Error receiving witness invitation: {err}") + + # Wait for connection to become active (if requested) + if wait_for_connection: + for attempt in range(max_retries): + if await self.get_active_connection(): + LOGGER.info("Connected to witness agent.") + return witness_id + await asyncio.sleep(retry_interval) + + LOGGER.info( + "No immediate response when trying to connect to witness agent. You can " + f"try manually setting up a connection with alias {witness_alias} or " + "restart the agent when witness is available." + ) + + return witness_id + + async def setup( + self, + config: dict = None, + update_config: bool = False, + require_controller: bool = True, + ) -> Optional[str]: + """Set up witness connection with unified logic. + + Args: + config: Optional configuration dict. If not provided, + will be fetched from profile. + update_config: If True, update config to add witness_id to witnesses list + require_controller: If True, only proceed if agent is configured as controller + + Returns: + The witness_id that was connected to, or None if setup was skipped + + Raises: + ConfigurationError: If no server_url is configured + """ + # Check if controller (if required) + if require_controller and not await is_controller(self.profile): + return None + + # Get configuration + if config is None: + config = await get_plugin_config(self.profile) + + if not config.get("server_url"): + if require_controller: + raise ConfigurationError("No server url configured.") + return None + + witness_id = config.get("witness_id") + + if not witness_id: + LOGGER.info("No witness identifier, can't create connection automatically.") + return None + + # Check if already connected + already_connected = await self.get_active_connection() + if already_connected: + LOGGER.info("Connected to witness from previous connection.") + else: + # Attempt to connect + try: + await self.connect(witness_id) + except OperationError as err: + LOGGER.info("Witness connection setup failed: %s", err) + return None + + # Update config if requested (regardless of connection status) + if update_config: + if witness_id not in config.get("witnesses", []): + config.setdefault("witnesses", []).append(witness_id) + await set_config(self.profile, config) + + return witness_id + + async def create_witness_invitation( + self, + witness_key: str, + alias: str = None, + label: str = None, + multi_use: bool = False, + ) -> dict: + """Create a witness invitation for controllers to connect. + + Args: + witness_key: The witness key multikey + alias: Optional alias for the invitation + label: Optional label for the witness service + multi_use: Whether the invitation can be used multiple times + + Returns: + Dictionary containing the invitation (with invitation_url key) + + Raises: + WitnessError: If invitation creation fails + """ + try: + invi_rec = await OutOfBandManager(self.profile).create_invitation( + hs_protos=[ + HSProto.get("https://didcomm.org/didexchange/1.0"), + HSProto.get("https://didcomm.org/didexchange/1.1"), + ], + alias=alias, + my_label=label, + goal_code="witness-service", + goal=f"did:key:{witness_key}", + multi_use=multi_use, + ) + return invi_rec.serialize() + except OutOfBandManagerError as e: + raise WitnessError(e) + + @staticmethod + def extract_invitation_url(invitation_record) -> str: + """Extract invitation URL from invitation record. + + Args: + invitation_record: Invitation record (dict or object) + + Returns: + The invitation URL, or None if not found + """ + if isinstance(invitation_record, dict): + return invitation_record.get("invitation_url") + return getattr(invitation_record, "invitation_url", None) + + @staticmethod + def format_invitation_url(invitation_url: str) -> str: + """Convert HTTP invitation URL to didcomm:// format if needed. + + Args: + invitation_url: The invitation URL (may be HTTP or didcomm format) + + Returns: + The formatted invitation URL (didcomm:// format if HTTP, otherwise unchanged) + """ + if invitation_url and invitation_url.startswith("http"): + from urllib.parse import urlparse, parse_qs + + parsed = urlparse(invitation_url) + query = parse_qs(parsed.query) + if "oob" in query: + oob_param = query["oob"][0] + return f"didcomm://?oob={oob_param}" + return invitation_url diff --git a/webvh/webvh/did/controller.py b/webvh/webvh/did/controller.py index 5497b0f91..7eeb62da0 100644 --- a/webvh/webvh/did/controller.py +++ b/webvh/webvh/did/controller.py @@ -5,21 +5,11 @@ import logging import re from uuid import uuid4 -from typing import Optional -from operator import itemgetter +from typing import Callable, Awaitable import uuid -from acapy_agent.connections.models.conn_record import ConnRecord -from acapy_agent.core.event_bus import Event, EventBus +from acapy_agent.core.event_bus import EventBus from acapy_agent.core.profile import Profile -from acapy_agent.messaging.models.base import BaseModelError -from acapy_agent.protocols.out_of_band.v1_0.manager import ( - OutOfBandManager, -) -from acapy_agent.protocols.out_of_band.v1_0.messages.invitation import ( - InvitationMessage, -) -from acapy_agent.resolver.did_resolver import DIDResolver from acapy_agent.wallet.askar import CATEGORY_DID from acapy_agent.wallet.keys.manager import ( multikey_to_verkey, @@ -31,31 +21,23 @@ add_scid_mapping, did_from_scid, get_plugin_config, - get_server_url, get_server_domain, - get_witnesses, - is_controller, is_witness, notify_watchers, - set_config, ) from ..protocols.attested_resource.record import PendingAttestedResourceRecord from ..protocols.log_entry.record import PendingLogEntryRecord -from ..protocols.states import WitnessingState +from ..protocols.states import WitnessingState, WitnessingStateHandler from .witness import WitnessManager -from .exceptions import ConfigurationError, DidCreationError, OperationError +from ..protocols.events import WitnessEventManager +from .connection import WebVHConnectionManager +from .utils import parse_webvh +from .key_chain import KeyChainManager +from .parameters import ParameterResolver +from .exceptions import DidCreationError from .server_client import WebVHServerClient, WebVHWatcherClient from .utils import ( - decode_invitation, - key_hash, multikey_to_jwk, - create_alias, - url_to_domain, - create_key, - find_key, - find_multikey, - bind_key, - unbind_key, add_proof, verify_proof, validate_did, @@ -63,9 +45,7 @@ LOGGER = logging.getLogger(__name__) -WEBVH_METHOD = "did:webvh:1.0" WITNESS_WAIT_TIMEOUT_SECONDS = 2 -WITNESS_EVENT = "witness_response::" PENDING_MESSAGE = { "status": WitnessingState.PENDING.value, "message": "The witness is pending.", @@ -79,82 +59,25 @@ def __init__(self, profile: Profile) -> None: """Initialize the DID Webvh Manager.""" self.profile = profile self.witness = WitnessManager(self.profile) + self.event_manager = WitnessEventManager(self.profile) + self.witness_connection = WebVHConnectionManager(self.profile) + self.key_chain = KeyChainManager(self.profile) + self.parameter_resolver = ParameterResolver(self.profile) + self.state_handler = WitnessingStateHandler(self.profile, self.event_manager) self.pending_log_entries = PendingLogEntryRecord() self.pending_attested_resource = PendingAttestedResourceRecord() self.server_client = WebVHServerClient(self.profile) self.watcher_client = WebVHWatcherClient(self.profile) - async def _get_active_witness_connection(self) -> Optional[ConnRecord]: - try: - server_url = await get_server_url(self.profile) - except ConfigurationError: - # No server_url configured yet, so no active connection possible - return None - - witness_alias = create_alias(url_to_domain(server_url), "witnessConnection") - async with self.profile.session() as session: - connection_records = await ConnRecord.retrieve_by_alias( - session, witness_alias - ) - - active_connections = [ - conn for conn in connection_records if conn.state == "active" - ] - - if len(active_connections) > 0: - return active_connections[0] - - return None - async def _sign_log_entry(self, log_entry): did = log_entry.get("state", {}).get("id", None) - update_key = await find_key(self.profile, f"{did}#updateKey") + update_key = await self.key_chain.update_key(did) return await add_proof( self.profile, log_entry, f"did:key:{update_key}#{update_key}", ) - async def _set_parameters_input(self, placeholder_id, options): - # Method - # https://identity.foundation/didwebvh/next/#didwebvh-did-method-parameters - parameters = {"method": WEBVH_METHOD} - - # Portability - # https://identity.foundation/didwebvh/next/#did-portability - if options.get("portable", False): - parameters["portable"] = True - - # Witness - # https://identity.foundation/didwebvh/next/#did-witnesses - # Support both camelCase and snake_case for backward compatibility - witness_threshold = options.get("witnessThreshold") or options.get("witness_threshold", 0) - if witness_threshold: - parameters["witness"] = { - "threshold": witness_threshold, - "witnesses": [ - {"id": witness} for witness in await get_witnesses(self.profile) - ], - } - - # Watchers - # https://identity.foundation/didwebvh/next/#did-watchers - if options.get("watchers", []): - parameters["watchers"] = options.get("watchers") - - # Provision Update Key - # https://identity.foundation/didwebvh/next/#authorized-keys - update_key = await create_key(self.profile, f"{placeholder_id}#updateKey") - parameters["updateKeys"] = [update_key] - - # Provision Rotation Key - # https://identity.foundation/didwebvh/next/#pre-rotation-key-hash-generation-and-verification - if options.get("prerotation", False): - next_key = await create_key(self.profile, f"{placeholder_id}#nextKey") - parameters["nextKeyHashes"] = [key_hash(next_key)] - - return parameters - async def _request_witness_signature(self, request_id): if await is_witness(self.profile): return PENDING_MESSAGE @@ -172,8 +95,8 @@ async def _request_witness_signature(self, request_id): } async def _save_local_did(self, did): - scid, domain, namespace, identifier = itemgetter(2, 3, 4, 5)(did.split(":")) - signing_key = await find_key(self.profile, f"{did}#signingKey") + parsed = parse_webvh(did) + signing_key = await self.key_chain.signing_key(did) async with self.profile.session() as session: await session.handle.insert( CATEGORY_DID, @@ -183,10 +106,10 @@ async def _save_local_did(self, did): "verkey": multikey_to_verkey(signing_key) if signing_key else None, "metadata": { "posted": True, - "scid": scid, - "domain": domain, - "namespace": namespace, - "identifier": identifier, + "scid": parsed.scid, + "domain": parsed.domain, + "namespace": parsed.namespace, + "identifier": parsed.identifier, }, "method": "webvh", "key_type": "ed25519", @@ -203,66 +126,18 @@ def _create_didcomm_service(self, did_doc): "recipientKeys": [did_doc.get("authentication", None)[0]], } - async def _fire_pending_event(self, record_id, log_entry): - event_bus = self.profile.inject(EventBus) - await event_bus.notify( - self.profile, - Event( - f"{WITNESS_EVENT}{record_id}", - { - "document": log_entry, - "metadata": { - "state": WitnessingState.PENDING.value, - }, - }, - ), - ) - - async def _fire_attested_event(self, record_id, log_entry, witness_signature=None): - event_bus = self.profile.inject(EventBus) - await event_bus.notify( - self.profile, - Event( - f"{WITNESS_EVENT}{record_id}", - { - "document": log_entry, - "witness_signature": witness_signature, - "metadata": {"state": WitnessingState.ATTESTED.value}, - }, - ), - ) - - async def _fire_post_attested_event(self, record_id, did): - async with self.profile.session() as session: - resolver = session.inject(DIDResolver) - - resolved_did_doc = ( - await resolver.resolve_with_metadata(self.profile, did) - ).serialize() - - event_bus = self.profile.inject(EventBus) - - metadata = resolved_did_doc["metadata"] - metadata["state"] = WitnessingState.ATTESTED.value - await event_bus.notify( - self.profile, - Event( - f"{WITNESS_EVENT}{record_id}", - {"document": resolved_did_doc["did_document"], "metadata": metadata}, - ), - ) - async def _create_preliminary_doc(self, placeholder_id): # Create a signing key - signing_key = await create_key(self.profile) + signing_key = await self.key_chain.create_key() public_signing_key_id = f"{placeholder_id}#{signing_key}" - await bind_key(self.profile, signing_key, f"{placeholder_id}#signingKey") - await bind_key(self.profile, signing_key, public_signing_key_id) + + # Bind signing key for placeholder DID + await self.key_chain.bind_key(signing_key, public_signing_key_id) # Bind parallel DID # https://identity.foundation/didwebvh/next/#publishing-a-parallel-didweb-did did_web = placeholder_id.replace(r"did:webvh:{SCID}:", "did:web:") - await bind_key(self.profile, signing_key, f"{did_web}#{signing_key}") + await self.key_chain.bind_key(signing_key, f"{did_web}#{signing_key}") return { "@context": [ @@ -288,7 +163,6 @@ async def _create_initial_log_entry( ): # We update the key id's stored during the preliminary log entry processing placeholder_id = preliminary_doc.get("id") - update_key = await find_key(self.profile, f"{placeholder_id}#updateKey") doc_state = DocumentState.initial( parameters_input, preliminary_doc, timestamp=timestamp ) @@ -296,46 +170,34 @@ async def _create_initial_log_entry( document = initial_log_entry.get("state") did = document.get("id") - await bind_key(self.profile, update_key, f"{did}#updateKey") - - # Update default signing key - signing_key = await find_key(self.profile, f"{placeholder_id}#signingKey") - await bind_key(self.profile, signing_key, f"{did}#signingKey") - await bind_key(self.profile, signing_key, f"{did}#{signing_key}") - - # Update prerotation key + # Migrate keys from placeholder DID to final DID + await self.key_chain.migrate_key(placeholder_id, did, "signingKey") + await self.key_chain.migrate_key(placeholder_id, did, "updateKey") if parameters_input.get("nextKeyHashes"): - next_key = await find_key(self.profile, f"{placeholder_id}#nextKey") - await bind_key(self.profile, next_key, f"{did}#nextKey") + await self.key_chain.migrate_key(placeholder_id, did, "nextKey") return initial_log_entry - async def _wait_for_log_entry(self, record_id: str): - event_bus = self.profile.inject(EventBus) - with event_bus.wait_for_event( - self.profile, re.compile(rf"^{WITNESS_EVENT}{record_id}$") - ) as await_event: - event = await await_event - if ( - event.payload.get("metadata", {}).get("state") - == WitnessingState.PENDING.value - ): - return PENDING_MESSAGE - else: - await self.pending_log_entries.remove_pending_record_id( - self.profile, record_id - ) - return await self.finish_did_operation( - event.payload.get("document"), - event.payload.get("witness_signature", None), - state=WitnessingState.FINISHED.value, - record_id=record_id, - ) + async def _wait_for_witness_event( + self, + record_id: str, + pending_record_manager, + handler: Callable[[dict, str], Awaitable], + ): + """Generic method to wait for witness events. - async def _wait_for_resource(self, record_id: str): + Args: + record_id: The record ID to wait for + pending_record_manager: The pending record manager to use for cleanup + handler: Async function to call when event is received (not pending) + Should accept (event_payload, record_id) and return result + + Returns: + Result from handler or PENDING_MESSAGE if event is pending + """ event_bus = self.profile.inject(EventBus) with event_bus.wait_for_event( - self.profile, re.compile(rf"^{WITNESS_EVENT}{record_id}$") + self.profile, re.compile(self.event_manager.get_event_pattern(record_id)) ) as await_event: event = await await_event if ( @@ -344,116 +206,50 @@ async def _wait_for_resource(self, record_id: str): ): return PENDING_MESSAGE else: - await self.pending_attested_resource.remove_pending_record_id( + await pending_record_manager.remove_pending_record_id( self.profile, record_id ) - document = event.payload.get("document") - await self.upload_resource( - document, state=WitnessingState.FINISHED.value, record_id=record_id - ) - - async def _apply_config_defaults(self, options: dict, defaults: dict): - """Apply default parameter options if not overwritten by request. + return await handler(event.payload, record_id) - options: The user provided did creation options. - defaults: The default configured options. + async def _wait_for_log_entry(self, record_id: str): + """Wait for log entry witness event.""" + + async def handler(event_payload, record_id): + return await self.finish_did_operation( + event_payload.get("document"), + event_payload.get("witness_signature", None), + state=WitnessingState.FINISHED.value, + record_id=record_id, + ) - """ - options["portability"] = options.get( - "portability", defaults.get("portability", False) - ) - options["prerotation"] = options.get( - "prerotation", defaults.get("prerotation", False) + return await self._wait_for_witness_event( + record_id, self.pending_log_entries, handler ) - options["witness_threshold"] = options.get( - "witness_threshold", defaults.get("witness_threshold", 0) - ) - options["watchers"] = options.get("watchers", defaults.get("watchers", None)) - return options - - async def _apply_policy(self, parameters: dict, options: dict): - """Apply server policy to did creation options. - - parameters: The parameters object returned by the server, - based on the configured policies. - - options: The user provided did creation options. - """ - if parameters.get("witness", {}).get("threshold", 0): - options["witness_threshold"] = parameters.get("witness").get("threshold") - - if parameters.get("watchers", None): - options["watchers"] = parameters.get("watchers") - - if parameters.get("portability", False): - options["portability"] = parameters.get("portability") + async def _wait_for_resource(self, record_id: str): + """Wait for resource witness event.""" - if parameters.get("nextKeyHashes", None) == []: - options["prerotation"] = True + async def handler(event_payload, record_id): + document = event_payload.get("document") + await self.upload_resource( + document, state=WitnessingState.FINISHED.value, record_id=record_id + ) - return options + return await self._wait_for_witness_event( + record_id, self.pending_attested_resource, handler + ) async def configure(self, config: dict) -> dict: - """Configure did controller and/or witness.""" - - # Connect to witness service (only if not self-witness) - if witness_id := config.get("witness_id"): - await self.connect_to_witness(witness_id) - - if witness_id not in config.get("witnesses", []): - config.setdefault("witnesses", []).append(witness_id) - await set_config(self.profile, config) + """Configure did controller. + This method only stores the configuration. Witness connection setup + is handled separately via WebVHConnectionManager.setup() or can be + done lazily when needed. + """ + # Configuration is already stored by the route handler + # No need to establish witness connection here return config - async def connect_to_witness(self, witness_id: str) -> str: - """Process witness invitation and connect.""" - - with WebVHServerClient(self.profile) as server_client: - invitation = await server_client.get_witness_invitation(witness_id) - - if not invitation: - raise OperationError( - f"Witness {witness_id} not listed by server document." - ) - - if invitation.get("goal-code", None) != "witness-service": - raise OperationError("Missing invitation goal-code and witness did.") - - if invitation.get("goal", None) != witness_id: - raise OperationError("Wrong invitation goal must match witness id.") - - # Get the witness connection is already set up - if await self._get_active_witness_connection(): - LOGGER.info("Connected to witness from previous connection.") - return witness_id - - try: - server_domain = await get_server_domain(self.profile) - witness_alias = f"webvh:{server_domain}@witness" - await OutOfBandManager(self.profile).receive_invitation( - invitation=invitation, - auto_accept=True, - alias=witness_alias, - ) - except BaseModelError as err: - raise OperationError(f"Error receiving witness invitation: {err}") - - for _ in range(5): - if await self._get_active_witness_connection(): - LOGGER.info("Connected to witness agent.") - return witness_id - - await asyncio.sleep(1) - - LOGGER.info( - "No immediate response when trying to connect to witness agent. You can " - f"try manually setting up a connection with alias {witness_alias} or " - "restart the agent when witness is available." - ) - return witness_id - async def create(self, options: dict): """Create DID and first log entry.""" @@ -472,31 +268,30 @@ async def create(self, options: dict): if not validate_did(placeholder_id, domain, namespace, identifier): raise DidCreationError(f"Server returned invalid did: {placeholder_id}") + # Resolve parameters (apply defaults, policy, and build parameters dict) config = await get_plugin_config(self.profile) - options = await self._apply_config_defaults( - options, config.get("parameter_options", {}) + ( + resolved_options, + parameters_input, + ) = await self.parameter_resolver.resolve_and_build( + placeholder_id=placeholder_id, + user_options=options, + config_defaults=config.get("parameter_options", {}), + server_parameters=requested_identifier.get("parameters"), + apply_policy=options.get("apply_policy", False), ) - # Apply provided options & policies to the requested identifier - if options.get("apply_policy", False): - options = await self._apply_policy( - requested_identifier.get("parameters"), options - ) - # Add a verification method to the initial state document & create preliminary doc preliminary_doc = await self._create_preliminary_doc(placeholder_id) - if options.get("didcomm", False): + if resolved_options.get("didcomm", False): preliminary_doc["service"].append( self._create_didcomm_service(preliminary_doc) ) - # Create update keys and set parameters - parameters_input = await self._set_parameters_input(placeholder_id, options) - # Create and sign initial log entry initial_log_entry = await self._create_initial_log_entry( - preliminary_doc, parameters_input, options.get("version_time", None) + preliminary_doc, parameters_input, resolved_options.get("version_time", None) ) return await self._sign_log_entry(initial_log_entry) @@ -510,7 +305,7 @@ async def update(self, scid: str, did_document: dict = None, options: dict = Non # Process prerotation if parameters.get("nextKeyHashes"): - update_key, next_key_hash = await self._rotate_update_key(did) + update_key, next_key_hash = await self.key_chain.rotate_update_key(did) params_update["updateKeys"] = [update_key] params_update["nextKeyHashes"] = [next_key_hash] @@ -530,7 +325,7 @@ async def deactivate(self, scid: str, options: dict = None): # Process prerotation if parameters.get("nextKeyHashes"): - update_key, next_key_hash = await self._rotate_update_key(did) + update_key, next_key_hash = await self.key_chain.rotate_update_key(did) params_update["nextKeyHashes"] = [next_key_hash] log_entry = document_state.create_next( @@ -547,7 +342,7 @@ async def streamline_did_operation(self, log_entry): # Process witnessing did = log_entry.get("state", {}).get("id", None) - scid = itemgetter(2)(did.split(":")) + parsed = parse_webvh(did) document_state = DocumentState.load_history_line( log_entry, await self.server_client.fetch_document_state(did) ) @@ -555,7 +350,7 @@ async def streamline_did_operation(self, log_entry): if document_state.witness_rule: witness_request_id = str(uuid.uuid4()) witness_signature = await self.witness.witness_log_entry( - scid, log_entry, witness_request_id + parsed.scid, log_entry, witness_request_id ) if not isinstance(witness_signature, dict): @@ -576,48 +371,33 @@ async def finish_did_operation( ): """Finish all DID operations.""" - # Process witnessing states - if state == WitnessingState.ATTESTED.value: - await self._fire_attested_event(record_id, log_entry, witness_signature) - - await asyncio.sleep(WITNESS_WAIT_TIMEOUT_SECONDS) - record_ids = await self.pending_log_entries.get_pending_record_ids( - self.profile - ) - - if record_id is None or record_id not in record_ids: - return - - await self.pending_log_entries.remove_pending_record_id( - self.profile, record_id - ) - - if state == WitnessingState.PENDING.value: - await self._fire_pending_event(log_entry, record_id) - return - - # Publish log entry - response_json = await self.server_client.submit_log_entry( - log_entry, witness_signature + async def submit_handler(document, sig): + """Submit log entry to server.""" + return await self.server_client.submit_log_entry(document, sig) + + async def post_process_handler(did): + """Post-process after submission.""" + # Process local did records + if log_entry.get("versionId")[0] == "1": + await self._save_local_did(did) + await add_scid_mapping(self.profile, did) + + # Process watchers + if await notify_watchers(self.profile): + watchers = log_entry.get("parameters").get("watchers", []) + await self.watcher_client.notify_watchers(did, watchers) + + return await self.state_handler.process_state( + state=state, + record_id=record_id, + document=log_entry, + witness_signature=witness_signature, + pending_record_manager=self.pending_log_entries, + submit_handler=submit_handler, + document_type="log_entry", + post_process_handler=post_process_handler, ) - # Process local did records - did = log_entry["state"]["id"] - if log_entry.get("versionId")[0] == "1": - await self._save_local_did(did) - await add_scid_mapping(self.profile, did) - else: - pass - - await self._fire_post_attested_event(record_id, did) - - # Process watchers - if await notify_watchers(self.profile): - watchers = log_entry.get("parameters").get("watchers", []) - await self.watcher_client.notify_watchers(did, watchers) - - return response_json - async def add_verification_method( self, scid: str, @@ -633,9 +413,9 @@ async def add_verification_method( did_document = scid_info.value_json.get("didDocument") did = did_document.get("id") multikey = ( - await find_multikey(self.profile, multikey) + await self.key_chain.find_multikey(multikey) if multikey - else await create_key(self.profile) + else await self.key_chain.create_key() ) if key_type == "Multikey": verification_method = { @@ -653,7 +433,9 @@ async def add_verification_method( "publicKeyJwk": jwk, } - await bind_key(multikey, verification_method["id"]) + await self.key_chain.bind_verification_method( + did, verification_method["id"], multikey + ) did_document["verificationMethod"].append(verification_method) for relationship in relationships: did_document[relationship].append(verification_method["id"]) @@ -663,36 +445,9 @@ async def add_verification_method( async def remove_verification_method(self, scid: str, key_id: str): """Remove a verification method.""" did = await did_from_scid(self.profile, scid) - key_id = f"{did}#{key_id}" - multikey = await find_key(self.profile, key_id) - await unbind_key(self.profile, multikey, key_id) + await self.key_chain.unbind_verification_method(did, key_id) return {"status": "ok"} - async def _rotate_update_key(self, did: str): - """Pre rotation.""" - next_key_id = f"{did}#nextKey" - update_key_id = f"{did}#updateKey" - - previous_next_key = await find_key(self.profile, next_key_id) - previous_update_key = await find_key(self.profile, update_key_id) - - # Unbind previous update key - await unbind_key(self.profile, previous_update_key, update_key_id) - - # Bind previous next key to new update key - await bind_key(self.profile, previous_next_key, update_key_id) - - # Unbind previous next key - await unbind_key(self.profile, previous_next_key, next_key_id) - - # Create and bind new next key - next_key = await create_key(self.profile, next_key_id) - - # Find new update key - update_key = await find_key(self.profile, update_key_id) - - return update_key, key_hash(next_key) - async def update_whois(self, scid: str, presentation: dict, options: dict = {}): """Update WHOIS linked VP.""" @@ -728,69 +483,18 @@ async def update_whois(self, scid: str, presentation: dict, options: dict = {}): async def upload_resource(self, attested_resource, state, record_id): """Upload an attested resource to the server.""" - if state == WitnessingState.ATTESTED.value: - event_bus = self.profile.inject(EventBus) - await event_bus.notify( - self.profile, - Event( - f"{WITNESS_EVENT}{record_id}", - { - "document": attested_resource, - "metadata": {"state": WitnessingState.ATTESTED.value}, - }, - ), - ) - await asyncio.sleep(WITNESS_WAIT_TIMEOUT_SECONDS) - record_ids = await self.pending_attested_resource.get_pending_record_ids( - self.profile - ) - if record_id is None or record_id not in record_ids: - return - await self.pending_attested_resource.remove_pending_record_id( - self.profile, record_id - ) - - if state == WitnessingState.PENDING.value: - event_bus = self.profile.inject(EventBus) - await event_bus.notify( - self.profile, - Event( - f"{WITNESS_EVENT}{record_id}", - { - "document": attested_resource, - "metadata": { - "state": WitnessingState.PENDING.value, - }, - }, - ), - ) - return - - await self.server_client.upload_attested_resource(attested_resource) - - async def auto_setup(self, config: dict | None = None) -> None: - """Automatically set up the witness connection for controllers.""" - if not await is_controller(self.profile): - return - # Get the witness connection is already set up - if await self._get_active_witness_connection(): - LOGGER.info("Connected to witness from previous connection.") - return - - config = config or await get_plugin_config(self.profile) - if not config.get("server_url"): - raise ConfigurationError("No server url configured.") - - witness_id = config.get("witness_id") - - if not witness_id: - LOGGER.info( - "No witness identifier, can't create connection automatically." - ) - return - - try: - await self.connect_to_witness(witness_id) - except OperationError as err: - LOGGER.info("Automatic witness connection failed: %s", err) \ No newline at end of file + async def submit_handler(document, sig): + """Upload resource to server.""" + await self.server_client.upload_attested_resource(document) + return {"status": "ok"} + + return await self.state_handler.process_state( + state=state, + record_id=record_id, + document=attested_resource, + witness_signature=None, + pending_record_manager=self.pending_attested_resource, + submit_handler=submit_handler, + document_type="attested_resource", + ) diff --git a/webvh/webvh/did/key_chain.py b/webvh/webvh/did/key_chain.py new file mode 100644 index 000000000..6a6edd99d --- /dev/null +++ b/webvh/webvh/did/key_chain.py @@ -0,0 +1,259 @@ +"""Key Chain Manager for handling all key operations.""" + +import logging +from typing import Optional + +from acapy_agent.core.profile import Profile +from acapy_agent.wallet.keys.manager import MultikeyManager +from multiformats import multibase, multihash + +LOGGER = logging.getLogger(__name__) + + +class KeyChainManager: + """Manages all key operations including creation, finding, binding, and unbinding.""" + + def __init__(self, profile: Profile): + """Initialize the KeyChainManager with a profile.""" + self.profile = profile + + async def create_key(self, kid: Optional[str] = None) -> str: + """Create a new key. + + Args: + kid: Optional key ID to bind the key to + + Returns: + The multikey string + """ + async with self.profile.session() as session: + key = await MultikeyManager(session).create(alg="ed25519", kid=kid) + return key.get("multikey") + + async def find_key(self, kid: str) -> Optional[str]: + """Find a key by its key ID. + + Args: + kid: The key ID to search for + + Returns: + The multikey string if found, None otherwise + """ + try: + async with self.profile.session() as session: + key = await MultikeyManager(session).from_kid(kid=kid) + return key.get("multikey") + except AttributeError: + return None + + async def get_key(self, kid: str, error_class: type[Exception] = KeyError) -> str: + """Get a key by its key ID, raising an error if not found. + + Args: + kid: The key ID to search for + error_class: Exception class to raise if key not found (default: KeyError) + + Returns: + The multikey string + + Raises: + error_class: If the key is not found + """ + key = await self.find_key(kid) + if not key: + raise error_class(f"Key [{kid}] not found.") + return key + + async def find_multikey(self, multikey: str) -> str: + """Find a key by its multikey. + + Args: + multikey: The multikey string to search for + + Returns: + The multikey string + """ + async with self.profile.session() as session: + key = await MultikeyManager(session).from_multikey(multikey) + return key.get("multikey") + + async def bind_key(self, multikey: str, kid: str) -> str: + """Bind a key to a given key ID. + + Args: + multikey: The multikey string to bind + kid: The key ID to bind to + + Returns: + The multikey string + """ + async with self.profile.session() as session: + key = await MultikeyManager(session).update(kid=kid, multikey=multikey) + return key.get("multikey") + + async def unbind_key(self, multikey: str, kid: str) -> None: + """Unbind a key ID from a key. + + Args: + multikey: The multikey string + kid: The key ID to unbind + """ + async with self.profile.session() as session: + await MultikeyManager(session).unbind_key_id(kid=kid, multikey=multikey) + + def key_hash(self, key: str) -> str: + """Calculate the hash of a key. + + Args: + key: The key string to hash + + Returns: + The base58btc encoded multihash + """ + return multibase.encode(multihash.digest(key.encode(), "sha2-256"), "base58btc")[ + 1: + ] + + async def update_key(self, did: str) -> Optional[str]: + """Find the update key for a DID. + + Args: + did: The DID to get the update key for + + Returns: + The update key multikey if found, None otherwise + """ + return await self.find_key(f"{did}#updateKey") + + async def signing_key(self, did: str) -> Optional[str]: + """Find the signing key for a DID. + + Args: + did: The DID to get the signing key for + + Returns: + The signing key multikey if found, None otherwise + """ + return await self.find_key(f"{did}#signingKey") + + async def next_key(self, did: str) -> Optional[str]: + """Find the next key for a DID. + + Args: + did: The DID to get the next key for + + Returns: + The next key multikey if found, None otherwise + """ + return await self.find_key(f"{did}#nextKey") + + async def migrate_key( + self, + from_did: str, + to_did: str, + key_type: str, + ) -> Optional[str]: + """Migrate a single key from one DID to another. + + This is useful when transitioning from a placeholder DID to the final DID. + + Args: + from_did: Source DID to get key from + to_did: Target DID to bind key to + key_type: Key type to migrate (e.g., "signingKey", "updateKey", "nextKey") + + Returns: + The migrated key multikey if found and migrated, None otherwise + """ + # Use convenience methods for common key types + if key_type == "signingKey": + multikey = await self.signing_key(from_did) + elif key_type == "updateKey": + multikey = await self.update_key(from_did) + elif key_type == "nextKey": + multikey = await self.next_key(from_did) + else: + multikey = await self.find_key(f"{from_did}#{key_type}") + + if multikey: + if key_type == "signingKey": + # Signing key needs multiple bindings + await self.bind_key(multikey, f"{to_did}#signingKey") + await self.bind_key(multikey, f"{to_did}#{multikey}") + else: + await self.bind_key(multikey, f"{to_did}#{key_type}") + return multikey + + return None + + async def rotate_update_key(self, did: str) -> tuple[str, str]: + """Rotate the update key using the next key. + + This implements the prerotation pattern: + 1. Unbind current update key + 2. Bind next key as new update key + 3. Unbind old next key + 4. Create and bind new next key + + Args: + did: The DID to rotate keys for + + Returns: + Tuple of (new_update_key, new_next_key_hash) + """ + next_key_id = f"{did}#nextKey" + update_key_id = f"{did}#updateKey" + + # Get existing keys + previous_next_key = await self.next_key(did) + previous_update_key = await self.update_key(did) + + # Unbind previous update key + if previous_update_key: + await self.unbind_key(previous_update_key, update_key_id) + + # Bind previous next key as new update key + if previous_next_key: + await self.bind_key(previous_next_key, update_key_id) + + # Unbind previous next key + await self.unbind_key(previous_next_key, next_key_id) + + # Create and bind new next key + next_key = await self.create_key(next_key_id) + + # Get the new update key (which is the previous next key) + new_update_key = await self.update_key(did) + + return new_update_key, self.key_hash(next_key) + + async def bind_verification_method( + self, did: str, key_id: str, multikey: str + ) -> None: + """Bind a verification method key. + + Args: + did: The DID + key_id: The key ID (can be relative like "key1" or full like "did#key1") + multikey: The multikey to bind + """ + # Ensure key_id is full format + if not key_id.startswith(did): + key_id = f"{did}#{key_id}" + + await self.bind_key(multikey, key_id) + + async def unbind_verification_method(self, did: str, key_id: str) -> None: + """Unbind a verification method key. + + Args: + did: The DID + key_id: The key ID (can be relative like "key1" or full like "did#key1") + """ + # Ensure key_id is full format + if not key_id.startswith(did): + key_id = f"{did}#{key_id}" + + multikey = await self.find_key(key_id) + if multikey: + await self.unbind_key(multikey, key_id) diff --git a/webvh/webvh/did/parameters.py b/webvh/webvh/did/parameters.py new file mode 100644 index 000000000..ffe4b8d22 --- /dev/null +++ b/webvh/webvh/did/parameters.py @@ -0,0 +1,205 @@ +"""Parameter Resolver for resolving DID creation parameters.""" + +import logging +from typing import Optional + +from acapy_agent.core.profile import Profile + +from ..config.config import get_witnesses +from .key_chain import KeyChainManager + +LOGGER = logging.getLogger(__name__) + +WEBVH_METHOD = "did:webvh:1.0" + + +class ParameterResolver: + """Resolves and builds DID creation parameters. + + Parameters are resolved from user options, defaults, and policies. + """ + + def __init__(self, profile: Profile): + """Initialize the ParameterResolver with a profile.""" + self.profile = profile + self.key_chain = KeyChainManager(profile) + + def apply_defaults(self, options: dict, defaults: dict) -> dict: + """Apply default parameter options if not overwritten by request. + + Args: + options: The user provided did creation options + defaults: The default configured options + + Returns: + Options dict with defaults applied + """ + resolved_options = options.copy() + + resolved_options["portability"] = resolved_options.get( + "portability", defaults.get("portability", False) + ) + resolved_options["prerotation"] = resolved_options.get( + "prerotation", defaults.get("prerotation", False) + ) + # Support both camelCase and snake_case for backward compatibility + witness_threshold = resolved_options.get( + "witnessThreshold" + ) or resolved_options.get( + "witness_threshold", defaults.get("witness_threshold", 0) + ) + resolved_options["witness_threshold"] = witness_threshold + resolved_options["watchers"] = resolved_options.get( + "watchers", defaults.get("watchers", None) + ) + + return resolved_options + + def apply_policy(self, server_parameters: dict, options: dict) -> dict: + """Apply server policy to did creation options. + + Args: + server_parameters: The parameters object returned by the server, + based on the configured policies + options: The user provided did creation options + + Returns: + Options dict with policy applied + """ + resolved_options = options.copy() + + # Apply witness threshold from policy + if server_parameters.get("witness", {}).get("threshold", 0): + resolved_options["witness_threshold"] = server_parameters.get("witness").get( + "threshold" + ) + + # Apply watchers from policy + if server_parameters.get("watchers", None): + resolved_options["watchers"] = server_parameters.get("watchers") + + # Apply portability from policy + if server_parameters.get("portability", False): + resolved_options["portability"] = server_parameters.get("portability") + + # Apply prerotation from policy (if nextKeyHashes is empty list, + # require prerotation) + if server_parameters.get("nextKeyHashes", None) == []: + resolved_options["prerotation"] = True + + return resolved_options + + async def build_parameters(self, placeholder_id: str, options: dict) -> dict: + """Build the parameters dict for DID creation. + + Args: + placeholder_id: The placeholder DID identifier + options: Resolved options dict (after defaults and policy) + + Returns: + Parameters dict ready for DID creation + """ + parameters = {"method": WEBVH_METHOD} + + # Portability + # https://identity.foundation/didwebvh/next/#did-portability + if options.get("portability", False): + parameters["portable"] = True + + # Witness + # https://identity.foundation/didwebvh/next/#did-witnesses + # Support both camelCase and snake_case for backward compatibility + witness_threshold = options.get("witnessThreshold") or options.get( + "witness_threshold", 0 + ) + if witness_threshold: + parameters["witness"] = { + "threshold": witness_threshold, + "witnesses": [ + {"id": witness} for witness in await get_witnesses(self.profile) + ], + } + + # Watchers + # https://identity.foundation/didwebvh/next/#did-watchers + if options.get("watchers", []): + parameters["watchers"] = options.get("watchers") + + # Provision Update Key + # https://identity.foundation/didwebvh/next/#authorized-keys + update_key = await self.key_chain.create_key(f"{placeholder_id}#updateKey") + parameters["updateKeys"] = [update_key] + + # Provision Rotation Key + # https://identity.foundation/didwebvh/next/#pre-rotation-key-hash-generation-and-verification + if options.get("prerotation", False): + next_key = await self.key_chain.create_key(f"{placeholder_id}#nextKey") + parameters["nextKeyHashes"] = [self.key_chain.key_hash(next_key)] + + return parameters + + async def resolve( + self, + user_options: dict, + config_defaults: dict, + server_parameters: Optional[dict] = None, + apply_policy: bool = False, + ) -> tuple[dict, dict]: + """Resolve parameters in one unified pass. + + This method: + 1. Applies config defaults to user options + 2. Optionally applies server policy + 3. Builds the final parameters dict + + Args: + user_options: User-provided DID creation options + config_defaults: Default options from configuration + server_parameters: Optional server policy parameters + apply_policy: Whether to apply server policy + + Returns: + Tuple of (resolved_options, parameters_dict) + Note: parameters_dict requires placeholder_id, so it's None here. + Use build_parameters() after getting placeholder_id. + """ + # Step 1: Apply defaults + resolved_options = self.apply_defaults(user_options, config_defaults) + + # Step 2: Apply policy if requested + if apply_policy and server_parameters: + resolved_options = self.apply_policy(server_parameters, resolved_options) + + return resolved_options, None + + async def resolve_and_build( + self, + placeholder_id: str, + user_options: dict, + config_defaults: dict, + server_parameters: Optional[dict] = None, + apply_policy: bool = False, + ) -> tuple[dict, dict]: + """Resolve parameters and build parameters dict in one call. + + This is a convenience method that combines resolve() and build_parameters(). + + Args: + placeholder_id: The placeholder DID identifier + user_options: User-provided DID creation options + config_defaults: Default options from configuration + server_parameters: Optional server policy parameters + apply_policy: Whether to apply server policy + + Returns: + Tuple of (resolved_options, parameters_dict) + """ + # Resolve options + resolved_options, _ = await self.resolve( + user_options, config_defaults, server_parameters, apply_policy + ) + + # Build parameters + parameters = await self.build_parameters(placeholder_id, resolved_options) + + return resolved_options, parameters diff --git a/webvh/webvh/did/server_client.py b/webvh/webvh/did/server_client.py index 65a6ec845..c22395110 100644 --- a/webvh/webvh/did/server_client.py +++ b/webvh/webvh/did/server_client.py @@ -4,13 +4,12 @@ import json import logging -from operator import itemgetter - from acapy_agent.core.profile import Profile from aiohttp import ClientConnectionError, ClientResponseError, ClientSession from did_webvh.core.state import DocumentState from ..config.config import get_server_url, use_strict_ssl +from .utils import parse_did_key, parse_webvh from .exceptions import DidCreationError, OperationError from .utils import all_are_not_none @@ -59,18 +58,21 @@ async def get_witness_invitation(self, witness_id: str): (svc for svc in witness_services if svc.get("id") == witness_id), None ) if not witness_service: + raise OperationError(f"Witness {witness_id} not listed by server document.") + + invitation_url = witness_service.get("serviceEndpoint") + parsed_key = parse_did_key(witness_id) + if ( + invitation_url + != f"{await get_server_url(self.profile)}?_oobid={parsed_key.key}" + ): raise OperationError( - f"Witness {witness_id} not listed by server document." + "Witness service endpoint does not match server document." ) - - invitation_url = witness_service.get("serviceEndpoint") - witness_key = witness_id.split(":")[-1] - if invitation_url != f"{await get_server_url(self.profile)}?_oobid={witness_key}": - raise OperationError("Witness service endpoint does not match server document.") - + async with ClientSession() as session: response = await session.get(invitation_url) - + return response.json() async def request_identifier(self, namespace, identifier) -> tuple: @@ -113,10 +115,11 @@ async def request_identifier(self, namespace, identifier) -> tuple: async def submit_log_entry(self, log_entry, witness_signature): """Submit a log entry to the WebVH server.""" did = log_entry.get("state", {}).get("id") - namespace, identifier = itemgetter(4, 5)(did.split(":")) + parsed = parse_webvh(did) async with ClientSession() as session: + server_url = await get_server_url(self.profile) response = await session.post( - f"{await get_server_url(self.profile)}/{namespace}/{identifier}", + f"{server_url}/{parsed.namespace}/{parsed.identifier}", json={"logEntry": log_entry, "witnessSignature": witness_signature}, ssl=(await use_strict_ssl(self.profile)), ) @@ -136,10 +139,11 @@ async def submit_log_entry(self, log_entry, witness_signature): async def fetch_jsonl(self, did: str): """Fetch a JSONL file from the given URL.""" - namespace, identifier = itemgetter(4, 5)(did.split(":")) + parsed = parse_webvh(did) async with ClientSession() as session: + server_url = await get_server_url(self.profile) async with session.get( - f"{await get_server_url(self.profile)}/{namespace}/{identifier}/did.jsonl" + f"{server_url}/{parsed.namespace}/{parsed.identifier}/did.jsonl" ) as response: # Check if the response is OK response.raise_for_status() @@ -165,12 +169,13 @@ async def fetch_document_state(self, did: str): async def submit_whois(self, vp: dict): """Submit a whois Verifiable Presentation for a given identifier.""" holder_id = vp.get("holder") - namespace, identifier = itemgetter(4, 5)(holder_id.split(":")) + parsed = parse_webvh(holder_id) async with ClientSession() as http_session: try: + server_url = await get_server_url(self.profile) response = await http_session.post( f""" - {await get_server_url(self.profile)}/{namespace}/{identifier}/whois + {server_url}/{parsed.namespace}/{parsed.identifier}/whois """, json={"verifiablePresentation": vp}, ) @@ -183,11 +188,11 @@ async def upload_attested_resource(self, resource: dict): """Submit a whois Verifiable Presentation for a given identifier.""" author_id = resource.get("id").split("/")[0] server_url = await get_server_url(self.profile) - namespace, identifier = itemgetter(4, 5)(author_id.split(":")) + parsed = parse_webvh(author_id) async with ClientSession() as http_session: try: response = await http_session.post( - f"{server_url}/{namespace}/{identifier}/resources", + f"{server_url}/{parsed.namespace}/{parsed.identifier}/resources", json={"attestedResource": resource}, ) except ClientConnectionError as err: diff --git a/webvh/webvh/did/tests/test_controller_manager.py b/webvh/webvh/did/tests/test_controller_manager.py index 3beb838b4..933f01ef4 100644 --- a/webvh/webvh/did/tests/test_controller_manager.py +++ b/webvh/webvh/did/tests/test_controller_manager.py @@ -1,12 +1,12 @@ from unittest import IsolatedAsyncioTestCase import uuid +from unittest import mock from acapy_agent.core.event_bus import EventBus from acapy_agent.messaging.responder import BaseResponder from acapy_agent.protocols.coordinate_mediation.v1_0.route_manager import RouteManager from acapy_agent.resolver.base import ResolutionMetadata, ResolutionResult, ResolverType from acapy_agent.resolver.did_resolver import DIDResolver -from acapy_agent.tests import mock from acapy_agent.utils.testing import create_test_profile from acapy_agent.wallet.key_type import KeyTypes from acapy_agent.wallet.keys.manager import MultikeyManager @@ -155,45 +155,87 @@ async def test_create_invalid(self): with self.assertRaises(ConfigurationError): await self.controller.create(options={}) - async def test_create_self_witness(self): + @mock.patch("webvh.did.server_client.WebVHServerClient.request_identifier") + @mock.patch("webvh.did.server_client.WebVHServerClient.submit_log_entry") + async def test_create_self_witness( + self, mock_submit_log_entry, mock_request_identifier + ): await set_config(self.profile, {"server_url": f"https://{TEST_DOMAIN}"}) + # Mock server_client.request_identifier to return valid response + # The identifier will be generated dynamically, so we need to capture it + # Note: The code expects {SCID} placeholder in the document ID + async def mock_request_identifier_impl(namespace, identifier): + return { + "versionId": SCID_PLACEHOLDER, + "versionTime": TEST_VERSION_TIME, + "parameters": { + "scid": SCID_PLACEHOLDER, + "method": "did:webvh:1.0", + "updateKeys": [], + "witness": { + "threshold": 1, + "witnesses": [{"id": f"did:key:{TEST_WITNESS_KEY}"}], + }, + }, + "state": { + "@context": ["https://www.w3.org/ns/did/v1"], + "id": f"did:webvh:{SCID_PLACEHOLDER}:{TEST_DOMAIN}:{namespace}:{identifier}", + }, + "proof": { + "type": "DataIntegrityProof", + "cryptosuite": "eddsa-jcs-2022", + "proofPurpose": "assertionMethod", + }, + } + + mock_request_identifier.side_effect = mock_request_identifier_impl + + # Mock server_client.submit_log_entry + async def mock_submit_log_entry_impl(*args, **kwargs): + return {"state": {"id": TEST_DID}} + + mock_submit_log_entry.side_effect = mock_submit_log_entry_impl + # Configure witness key - await self.controller.configure(options={"auto_attest": True, "witness": True}) + await self.controller.configure(config={"auto_attest": True, "witness": True}) # Create DID await self.controller.create(options={"namespace": TEST_NAMESPACE}) - @mock.patch("webvh.did.controller.decode_invitation") - @mock.patch("webvh.did.controller.InvitationMessage.from_url") - @mock.patch("webvh.did.controller.OutOfBandManager.receive_invitation") + @mock.patch("webvh.did.connection.OutOfBandManager") + @mock.patch("webvh.did.connection.WebVHServerClient") @mock.patch("asyncio.sleep", new_callable=mock.AsyncMock) async def test_connect_to_witness_uses_server_service( - self, _mock_sleep, mock_receive, mock_from_url, mock_decode + self, _mock_sleep, mock_server_client_class, mock_oob_manager ): desired_witness_id = f"did:key:{TEST_WITNESS_KEY}" - mock_decode.return_value = { - "goal": desired_witness_id, - "goal-code": "witness-service", - } - mock_from_url.return_value = mock.MagicMock() - self.controller.server_client.get_witness_services = mock.AsyncMock( - return_value=[ - { - "id": desired_witness_id, - "serviceEndpoint": "https://example.com?oob=mock", - } - ] + mock_receive = mock.AsyncMock() + mock_oob_manager.return_value.receive_invitation = mock_receive + + # Mock the server client + mock_server_client = mock.MagicMock() + mock_server_client.get_witness_invitation = mock.AsyncMock( + return_value={ + "goal": desired_witness_id, + "goal-code": "witness-service", + } ) + mock_server_client_class.return_value = mock_server_client + connection_checker = mock.AsyncMock(side_effect=[None, mock.MagicMock()]) with mock.patch.object( - self.controller, "_get_active_witness_connection", connection_checker + self.controller.witness_connection, + "get_active_connection", + connection_checker, ): - result = await self.controller.connect_to_witness( + result = await self.controller.witness_connection.connect( witness_id=desired_witness_id ) - self.controller.server_client.get_witness_services.assert_awaited_once() + mock_server_client.get_witness_invitation.assert_awaited_once_with( + desired_witness_id + ) mock_receive.assert_awaited_once() assert result == desired_witness_id @@ -223,8 +265,8 @@ async def test_finish_create(self): record_id, ) - witness_signature = await self.witness.sign_log_version( - TEST_LOG_ENTRY.get("versionId") + witness_signature = await self.witness.sign( + {"versionId": TEST_LOG_ENTRY.get("versionId")} ) await self.controller.finish_did_operation( log_entry=TEST_LOG_ENTRY, diff --git a/webvh/webvh/did/tests/test_witness_manager.py b/webvh/webvh/did/tests/test_witness_manager.py index bf1b00dc7..c2a4387d6 100644 --- a/webvh/webvh/did/tests/test_witness_manager.py +++ b/webvh/webvh/did/tests/test_witness_manager.py @@ -13,6 +13,7 @@ from ..exceptions import ConfigurationError from ..controller import ControllerManager from ..witness import WitnessManager +from ..connection import WebVHConnectionManager from ...config.config import get_plugin_config from ...protocols.log_entry.record import PendingLogEntryRecord @@ -53,19 +54,16 @@ async def asyncSetUp(self): async def test_witness_key_alias(self): assert self.witness.key_alias - async def test_witness_connection_alias(self): - assert self.witness.connection_alias - - @mock.patch.object(WitnessManager, "_get_active_witness_connection") - async def test_auto_witness_setup_as_witness( - self, mock_get_active_witness_connection - ): + @mock.patch.object(WebVHConnectionManager, "get_active_connection") + async def test_auto_witness_setup_as_witness(self, mock_get_active_connection): self.profile.settings.set_value( "plugin_config", {"webvh": {"witness": True, "server_url": SERVER_URL}}, ) - await self.controller.auto_setup() - assert not mock_get_active_witness_connection.called + await self.controller.witness_connection.setup( + update_config=False, require_controller=True + ) + assert not mock_get_active_connection.called async def test_auto_witness_setup_as_controller_no_server_url(self): self.profile.settings.set_value( @@ -73,7 +71,9 @@ async def test_auto_witness_setup_as_controller_no_server_url(self): {"webvh": {"witness": False}}, ) with self.assertRaises(ConfigurationError): - await self.controller.auto_setup() + await self.controller.witness_connection.setup( + update_config=False, require_controller=True + ) async def test_auto_witness_setup_as_controller_with_previous_connection(self): self.profile.settings.set_value( @@ -91,7 +91,9 @@ async def test_auto_witness_setup_as_controller_with_previous_connection(self): state="active", ) await record.save(session) - await self.controller.auto_setup() + await self.controller.witness_connection.setup( + update_config=False, require_controller=True + ) async def test_auto_witness_setup_as_controller_no_witness_invitation(self): self.profile.settings.set_value( @@ -103,7 +105,9 @@ async def test_auto_witness_setup_as_controller_no_witness_invitation(self): } }, ) - await self.controller.auto_setup() + await self.controller.witness_connection.setup( + update_config=False, require_controller=True + ) @mock.patch.object(OutOfBandManager, "receive_invitation") @mock.patch.object(asyncio, "sleep") @@ -122,7 +126,9 @@ async def test_auto_witness_setup_as_controller_no_active_connection(self, *_): self.profile.context.injector.bind_instance( RouteManager, mock.AsyncMock(RouteManager, autospec=True) ) - await self.controller.auto_setup() + await self.controller.witness_connection.setup( + update_config=False, require_controller=True + ) @mock.patch.object(OutOfBandManager, "receive_invitation") async def test_auto_witness_setup_as_controller_conn_becomes_active(self, *_): @@ -151,7 +157,9 @@ async def _create_connection(): await record.save(session) asyncio.create_task(_create_connection()) - await self.controller.auto_setup() + await self.controller.witness_connection.setup( + update_config=False, require_controller=True + ) async def test_witness_auto_setup_skips_when_not_configured(self): self.profile.settings.set_value( @@ -163,7 +171,7 @@ async def test_witness_auto_setup_skips_when_not_configured(self): } }, ) - await self.witness.auto_setup() + await self.witness.configure(log_message=True) config = await get_plugin_config(self.profile) assert "witnesses" not in config @@ -190,23 +198,31 @@ async def test_witness_auto_setup_creates_key_and_updates_config(self): ) witness = WitnessManager(profile) with mock.patch.object( - WitnessManager, - "create_invitation", + witness.witness_connection, + "create_witness_invitation", new=mock.AsyncMock(return_value={"invitation_url": "https://example.com"}), ): - await witness.auto_setup() + await witness.configure(log_message=True) config = await get_plugin_config(profile) assert config.get("witnesses") assert len(config["witnesses"]) == 1 # Ensure the witness key exists and can be retrieved - assert await witness.get_witness_key() - - async def test_witness_configure_delegates_to_controller(self): - options = {"server_url": SERVER_URL, "auto_attest": True} - with mock.patch( - "webvh.did.tests.test_witness_manager.ControllerManager.configure", - new=mock.AsyncMock(), - ) as mock_configure: - await self.witness.configure(options) - mock_configure.assert_awaited_once() + assert await witness.key_chain.get_key(witness.key_alias) + + @mock.patch.object(WebVHConnectionManager, "create_witness_invitation") + async def test_witness_configure_delegates_to_controller( + self, mock_create_invitation + ): + """Test that witness.configure() works independently (it doesn't delegate to controller).""" + # Mock the invitation creation + mock_create_invitation.return_value = { + "invitation_url": "https://example.com?oob=test123" + } + + options = {"server_url": SERVER_URL, "auto_attest": True, "witness": True} + # Witness.configure() doesn't delegate to ControllerManager.configure() + # It handles witness configuration independently + result = await self.witness.configure(options) + assert result.get("witness_id") is not None + assert result.get("witnesses") is not None diff --git a/webvh/webvh/did/utils.py b/webvh/webvh/did/utils.py index abd134273..0f26efbe5 100644 --- a/webvh/webvh/did/utils.py +++ b/webvh/webvh/did/utils.py @@ -3,6 +3,7 @@ import base64 import hashlib import json +from dataclasses import dataclass import jcs from multiformats import multibase, multihash @@ -21,6 +22,40 @@ } +@dataclass(frozen=True) +class ParsedWebVHDID: + """Parsed WebVH DID components.""" + + did: str + method: str # "webvh" + scid: str + domain: str + namespace: str + identifier: str + + def __str__(self) -> str: + """Return the full DID string.""" + return self.did + + @property + def namespace_identifier(self) -> tuple[str, str]: + """Get namespace and identifier as a tuple.""" + return (self.namespace, self.identifier) + + +@dataclass(frozen=True) +class ParsedDIDKey: + """Parsed did:key DID components.""" + + did: str + method: str # "key" + key: str + + def __str__(self) -> str: + """Return the full DID string.""" + return self.did + + def url_to_domain(url: str): """Get server domain.""" domain = url.split("://")[-1] @@ -68,15 +103,130 @@ def all_are_not_none(*args): return all(v is not None for v in args) -def get_namespace_and_identifier_from_did(did: str): - """Extract namespace and identifier from a DID.""" +def parse_webvh(did: str) -> ParsedWebVHDID: + """Parse a WebVH DID into its components. + + Expected format: did:webvh:{scid}:{domain}:{namespace}:{identifier} + + Args: + did: The DID string to parse + + Returns: + ParsedWebVHDID object with parsed components + + Raises: + ValueError: If the DID format is invalid + """ + parts = did.split(":") + if len(parts) < 6: + raise ValueError( + "Invalid WebVH DID format. " + f"Expected 'did:webvh:{{scid}}:{{domain}}:{{namespace}}:{{identifier}}', " + f"got: {did}" + ) + + if parts[0] != "did": + raise ValueError(f"Invalid DID format. Must start with 'did:', got: {did}") + + if parts[1] != "webvh": + raise ValueError(f"Invalid DID method. Expected 'webvh', got: {parts[1]}") + + return ParsedWebVHDID( + did=did, + method=parts[1], + scid=parts[2], + domain=parts[3], + namespace=parts[4], + identifier=parts[5], + ) + + +def parse_did_key(did: str) -> ParsedDIDKey: + """Parse a did:key DID into its components. + + Expected format: did:key:{key} + + Args: + did: The DID string to parse + + Returns: + ParsedDIDKey object with parsed components + + Raises: + ValueError: If the DID format is invalid + """ + parts = did.split(":") + if len(parts) < 3: + raise ValueError( + f"Invalid did:key format. Expected 'did:key:{{key}}', got: {did}" + ) + + if parts[0] != "did": + raise ValueError(f"Invalid DID format. Must start with 'did:', got: {did}") + + if parts[1] != "key": + raise ValueError(f"Invalid DID method. Expected 'key', got: {parts[1]}") + + # The key is everything after "did:key:" + key = ":".join(parts[2:]) + + return ParsedDIDKey(did=did, method=parts[1], key=key) + + +def parse_did(did: str) -> ParsedWebVHDID | ParsedDIDKey: + """Parse a DID, automatically detecting the format. + + Args: + did: The DID string to parse + + Returns: + ParsedWebVHDID or ParsedDIDKey object depending on DID method + + Raises: + ValueError: If the DID format is invalid or unsupported + """ parts = did.split(":") - if len(parts) < 5: + if len(parts) < 2: + raise ValueError(f"Invalid DID format: {did}") + + method = parts[1] + + if method == "webvh": + return parse_webvh(did) + elif method == "key": + return parse_did_key(did) + else: raise ValueError( - "Invalid DID format. Expected 'did:webvh:::'" + f"Unsupported DID method: {method}. Supported methods: webvh, key" ) - return parts[4], parts[5] + +def extract_key_from_did_key(did: str) -> str: + """Extract the key from a did:key DID. + + This is a convenience function for extracting just the key portion. + + Args: + did: The did:key string + + Returns: + The key portion of the DID + + Raises: + ValueError: If the DID format is invalid + """ + parsed = parse_did_key(did) + return parsed.key + + +def get_namespace_and_identifier_from_did(did: str): + """Extract namespace and identifier from a DID. + + This function is kept for backward compatibility. + Consider using parse_webvh() directly for new code. + """ + parsed = parse_webvh(did) + return parsed.namespace_identifier async def create_key(profile, kid=None) -> str: @@ -148,14 +298,59 @@ async def verify_proof(profile, document) -> bool: return verified -def validate_did(did: str, domain: str, namespace: str, identifier: str) -> bool: - """Validate a did aginst the components.""" - return ( - True - if ( - did.split(":")[3] == domain - and did.split(":")[4] == namespace - and did.split(":")[5] == identifier +def validate_webvh_did(did: str, domain: str, namespace: str, identifier: str) -> bool: + """Validate a did against the components. + + Args: + did: The DID string to validate + domain: Expected domain + namespace: Expected namespace + identifier: Expected identifier + + Returns: + True if the DID matches the components, False otherwise + """ + try: + parsed = parse_webvh(did) + return ( + parsed.domain == domain + and parsed.namespace == namespace + and parsed.identifier == identifier ) - else False - ) + except ValueError: + return False + + +def validate_did(did: str, domain: str, namespace: str, identifier: str) -> bool: + """Validate a did against the components. + + This function is kept for backward compatibility. + Consider using validate_webvh_did() directly for new code. + """ + return validate_webvh_did(did, domain, namespace, identifier) + + +def format_witness_ready_message(witness_id: str, invitation_url: str = None) -> str: + """Format a witness ready message for display and logging. + + Args: + witness_id: The witness DID identifier + invitation_url: Optional invitation URL (defaults to "") + + Returns: + Formatted message string + """ + invitation_display = invitation_url if invitation_url else "" + return f""" +{"=" * 70} +✨{" " * 20}WebVH Witness Ready!{" " * 20}✨ +{"=" * 70} + + 🔑 Witness ID: + {witness_id} + + 📨 Invitation URL: + {invitation_display} + +{"=" * 70} +""" diff --git a/webvh/webvh/did/witness.py b/webvh/webvh/did/witness.py index d37656b21..d12427c9e 100644 --- a/webvh/webvh/did/witness.py +++ b/webvh/webvh/did/witness.py @@ -4,20 +4,13 @@ import logging from typing import Optional -from acapy_agent.connections.models.conn_record import ConnRecord from acapy_agent.core.profile import Profile from acapy_agent.messaging.responder import BaseResponder -from acapy_agent.protocols.out_of_band.v1_0.manager import ( - OutOfBandManager, - OutOfBandManagerError, -) -from acapy_agent.protocols.out_of_band.v1_0.messages.invitation import ( - HSProto, -) from ..config.config import get_plugin_config, set_config -from .exceptions import WitnessError, ConfigurationError, OperationError +from .utils import parse_did_key +from .exceptions import WitnessError, ConfigurationError from ..protocols.attested_resource.record import PendingAttestedResourceRecord from ..protocols.attested_resource.messages import ( WitnessRequest as AttestedResourceWitnessRequest, @@ -30,7 +23,9 @@ ) from ..protocols.states import WitnessingState from ..did.server_client import WebVHServerClient -from ..did.utils import find_key, add_proof, create_key, url_to_domain, bind_key +from ..did.utils import add_proof, url_to_domain, format_witness_ready_message +from ..did.key_chain import KeyChainManager +from ..did.connection import WebVHConnectionManager LOGGER = logging.getLogger(__name__) @@ -42,66 +37,80 @@ def __init__(self, profile: Profile): """Initialize the witness manager.""" self.profile = profile self.server_client = WebVHServerClient(profile) + self.key_chain = KeyChainManager(profile) + self.witness_connection = WebVHConnectionManager(profile) self.proof_options = { "type": "DataIntegrityProof", "cryptosuite": "eddsa-jcs-2022", "proofPurpose": "assertionMethod", } - async def configure(self, config: dict) -> dict: + async def configure(self, config: dict = None, log_message: bool = False) -> dict: """Configure this agent as a witness. This creates the witness key, invitation, and updates the config. - Same logic as auto_setup but called from the configuration endpoint. + Can be called from the configuration endpoint or during startup. + + Args: + config: Configuration dict. If None, will be fetched from profile. + log_message: If True, log and print formatted witness ready message. + + Returns: + Config dict with invitation_url (for API responses) """ + if config is None: + config = await get_plugin_config(self.profile) + if not config.get("witness", False): + if log_message: + LOGGER.debug("Skipping witness configuration - witness not enabled") return config config.setdefault("witnesses", []) key_alias = self.key_alias - + # If witness_id is provided, try to use that key if witness_id := config.get("witness_id", None): - witness_key = witness_id.split(":")[-1] # Extract key from did:key:xxx - await bind_key(self.profile, witness_key, key_alias) + parsed_key = parse_did_key(witness_id) + witness_key = parsed_key.key + await self.key_chain.bind_key(witness_key, key_alias) + if log_message: + LOGGER.info("Using configured witness_id: %s", witness_id) else: # Otherwise, find existing key or create new one - witness_key = await find_key(self.profile, key_alias) + witness_key = await self.key_chain.find_key(key_alias) if not witness_key: LOGGER.info("Creating witness key for alias %s", key_alias) - witness_key = await create_key(self.profile, key_alias) + witness_key = await self.key_chain.create_key(key_alias) witness_id = f"did:key:{witness_key}" + # Store witness_id in config if not already set + if not config.get("witness_id"): + config["witness_id"] = witness_id + await set_config(self.profile, config) + if witness_id not in config["witnesses"]: config["witnesses"].append(witness_id) await set_config(self.profile, config) - # Use the witness_key we already have instead of calling get_witness_key() - invitation_record = await self.create_invitation( + # Create witness invitation + invitation_record = await self.witness_connection.create_witness_invitation( + witness_key=witness_key, alias=None, label="Witness Service", multi_use=True, - witness_key=witness_key, ) - invitation_url = None - if isinstance(invitation_record, dict): - invitation_url = invitation_record.get("invitation_url") - else: - invitation_url = getattr(invitation_record, "invitation_url", None) - - # Convert https:// URL to didcomm:// format - if invitation_url and invitation_url.startswith("http"): - from urllib.parse import urlparse, parse_qs - parsed = urlparse(invitation_url) - query = parse_qs(parsed.query) - if "oob" in query: - oob_param = query["oob"][0] - invitation_url = f"didcomm://?oob={oob_param}" - - # Store witness_id in config (but not invitation_url - it's generated on demand) - config["witness_id"] = witness_id - await set_config(self.profile, config) - + invitation_url = self.witness_connection.extract_invitation_url(invitation_record) + invitation_url = self.witness_connection.format_invitation_url(invitation_url) + + # Log and print formatted message if requested (for startup visibility) + if log_message: + message = format_witness_ready_message(witness_id, invitation_url) + # Log each line separately for better log parsing + for line in message.strip().split("\n"): + LOGGER.warning(line) + print(message, end="") + # Return config with invitation_url for API response (but don't persist it) response_config = config.copy() response_config["invitation_url"] = invitation_url @@ -117,130 +126,24 @@ def key_alias(self) -> str: domain = url_to_domain(server_url) return f"webvh:{domain}@witnessKey" - @property - def connection_alias(self) -> str: - """Derive witness connection alias.""" - # Reuse key_alias domain logic to keep behavior consistent - alias = self.key_alias - return alias.replace("@witnessKey", "@witness") - - async def auto_setup(self, config: dict | None = None): - """Automatically ensure the witness configuration is ready.""" - if config is None: - config = await get_plugin_config(self.profile) + async def sign(self, document: dict) -> dict: + """Sign a document with the witness key. - if not config.get("witness", False): - LOGGER.debug("Skipping witness auto_setup - witness not configured") - return - - LOGGER.warning("=" * 70) - LOGGER.warning("Starting witness auto_setup") - LOGGER.warning("Witness auto_setup: config.witness = %s", config.get("witness")) + Args: + document: The document to sign (dict) - config.setdefault("witnesses", []) - key_alias = self.key_alias - LOGGER.warning("Witness auto_setup: key_alias = %s", key_alias) - witness_key = await find_key(self.profile, key_alias) - if not witness_key: - LOGGER.warning("Creating witness key for alias %s", key_alias) - witness_key = await create_key(self.profile, key_alias) - LOGGER.warning("Created witness key: %s", witness_key[:20] + "..." if witness_key else "None") - - witness_id = f"did:key:{witness_key}" - if witness_id not in config["witnesses"]: - config["witnesses"].append(witness_id) - await set_config(self.profile, config) + Returns: + The signed document with proof added - # Use the witness_key we already have instead of calling get_witness_key() - invitation_record = await self.create_invitation( - alias=None, - label="Witness Service", - multi_use=True, - witness_key=witness_key, + Raises: + WitnessError: If witness key cannot be retrieved + """ + witness_key = await self.key_chain.get_key(self.key_alias, WitnessError) + return await add_proof( + self.profile, + document, + f"did:key:{witness_key}#{witness_key}", ) - invitation_url = None - if isinstance(invitation_record, dict): - invitation_url = invitation_record.get("invitation_url") - else: - invitation_url = getattr(invitation_record, "invitation_url", None) - - # Convert https:// URL to didcomm:// format - if invitation_url and invitation_url.startswith("http"): - from urllib.parse import urlparse, parse_qs - parsed = urlparse(invitation_url) - query = parse_qs(parsed.query) - if "oob" in query: - oob_param = query["oob"][0] - invitation_url = f"didcomm://?oob={oob_param}" - - # Format the witness configuration message - invitation_display = invitation_url if invitation_url else "" - message = f""" -{'=' * 70} -✨{' ' * 20}WebVH Witness Ready!{' ' * 20}✨ -{'=' * 70} - - 🔑 Witness ID: - {witness_id} - - 📨 Invitation URL: - {invitation_display} - -{'=' * 70} -""" - - # Log and print the same message - # Use WARNING level to ensure visibility in Docker logs - # Use multiple LOGGER.warning calls to ensure each line is logged separately - for line in message.strip().split('\n'): - LOGGER.warning(line) - print(message, end="") - - async def create_invitation(self, alias=None, label=None, multi_use=False, witness_key=None) -> str: - """Create a witness invitation.""" - if witness_key is None: - witness_key = await self.get_witness_key() - try: - invi_rec = await OutOfBandManager(self.profile).create_invitation( - hs_protos=[ - HSProto.get("https://didcomm.org/didexchange/1.0"), - HSProto.get("https://didcomm.org/didexchange/1.1"), - ], - alias=alias, - my_label=label, - goal_code="witness-service", - goal=f"did:key:{witness_key}", - multi_use=multi_use, - ) - return invi_rec.serialize() - except OutOfBandManagerError as e: - raise WitnessError(e) - - async def _get_active_witness_connection(self) -> Optional[ConnRecord]: - """Find active witness connection.""" - witness_alias = self.connection_alias - async with self.profile.session() as session: - connection_records = await ConnRecord.retrieve_by_alias( - session, witness_alias - ) - - active_connections = [ - conn for conn in connection_records if conn.state == "active" - ] - - if len(active_connections) > 0: - return active_connections[0] - - return None - - async def get_witness_key(self) -> str: - """Return the witness key.""" - witness_alias = self.key_alias - witness_key = await find_key(self.profile, witness_alias) - if not witness_key: - raise WitnessError(f"Witness key [{witness_alias}] not found.") - - return witness_key async def witness_log_entry( self, @@ -255,7 +158,7 @@ async def witness_log_entry( if config.get("witness", False): record = PendingLogEntryRecord() if config.get("auto_attest", False): - return await self.sign_log_version(log_entry.get("versionId")) + return await self.sign({"versionId": log_entry.get("versionId")}) await record.save_pending_record( self.profile, scid, log_entry, witness_request_id @@ -265,7 +168,9 @@ async def witness_log_entry( else: responder = self.profile.inject(BaseResponder) - witness_connection = await self._get_active_witness_connection() + witness_connection = await self.witness_connection.get_active_connection( + auto_connect=True + ) if not witness_connection: raise WitnessError("No active witness connection found.") @@ -289,12 +194,7 @@ async def witness_attested_resource( if config.get("witness", False): record = PendingAttestedResourceRecord() if config.get("auto_attest", False): - witness_key = await self.get_witness_key() - return await add_proof( - self.profile, - attested_resource, - f"did:key:{witness_key}#{witness_key}", - ) + return await self.sign(attested_resource) await record.save_pending_record( self.profile, scid, @@ -306,7 +206,9 @@ async def witness_attested_resource( else: responder = self.profile.inject(BaseResponder) - witness_connection = await self._get_active_witness_connection() + witness_connection = await self.witness_connection.get_active_connection( + auto_connect=True + ) if not witness_connection: raise WitnessError("No active witness connection found.") @@ -317,16 +219,6 @@ async def witness_attested_resource( connection_id=witness_connection.connection_id, ) - async def sign_log_version(self, version_id) -> dict: - """Sign a given log versionId with a DataIntegrityProof.""" - witness_key = await self.get_witness_key() - witness_signature = await add_proof( - self.profile, - {"versionId": version_id}, - f"did:key:{witness_key}#{witness_key}", - ) - return witness_signature - async def approve_log_entry( self, log_entry: dict, connection_id: str, request_id: str = None ) -> dict[str, str]: @@ -335,12 +227,7 @@ async def approve_log_entry( if not log_entry.get("proof", None): raise WitnessError("No proof found in log entry. Cannot witness.") - witness_key = await self.get_witness_key() - witness_signature = await add_proof( - self.profile, - {"versionId": log_entry.get("versionId")}, - f"did:key:{witness_key}#{witness_key}", - ) + witness_signature = await self.sign({"versionId": log_entry.get("versionId")}) if not connection_id: # NOTE: will have to review this behavior when witness threshold is > 1 @@ -371,21 +258,11 @@ async def approve_attested_resource( if not attested_resource.get("proof", None): raise WitnessError("No proof found in log entry. Cannot witness.") - witness_key = await self.get_witness_key() - witnessed_resource = await add_proof( - self.profile, - copy.deepcopy(attested_resource), - f"did:key:{witness_key}#{witness_key}", - ) + witnessed_resource = await self.sign(copy.deepcopy(attested_resource)) if not connection_id: # Upload resource to server - author_id = attested_resource.get("id").split("/")[0] - namespace = author_id.split(":")[4] - identifier = author_id.split(":")[5] - await self.server_client.upload_attested_resource( - namespace, identifier, witnessed_resource - ) + await self.server_client.upload_attested_resource(witnessed_resource) return attested_resource else: await self.profile.inject(BaseResponder).send( diff --git a/webvh/webvh/protocols/attested_resource/handlers.py b/webvh/webvh/protocols/attested_resource/handlers.py index e1415412a..99f2a702b 100644 --- a/webvh/webvh/protocols/attested_resource/handlers.py +++ b/webvh/webvh/protocols/attested_resource/handlers.py @@ -43,7 +43,9 @@ async def handle(self, context: RequestContext, responder: BaseResponder): config = await get_plugin_config(context.profile) connection_id = context.connection_record.connection_id if config.get("auto_attest", False): - witness_key = await witness.get_witness_key() + from ...did.exceptions import WitnessError + + witness_key = await witness.key_chain.get_key(witness.key_alias, WitnessError) witness_signature = await add_proof( context.profile, copy.deepcopy(attested_resource), diff --git a/webvh/webvh/protocols/events.py b/webvh/webvh/protocols/events.py new file mode 100644 index 000000000..ea69265cc --- /dev/null +++ b/webvh/webvh/protocols/events.py @@ -0,0 +1,143 @@ +"""Witness Event Manager for handling witness-related events.""" + +import logging +from typing import Optional + +from acapy_agent.core.event_bus import Event, EventBus +from acapy_agent.core.profile import Profile +from acapy_agent.resolver.did_resolver import DIDResolver + +from .states import WitnessingState + +LOGGER = logging.getLogger(__name__) + +WITNESS_EVENT_PREFIX = "witness_response::" + + +class WitnessEventManager: + """Manages witness-related event firing and handling.""" + + def __init__(self, profile: Profile): + """Initialize the WitnessEventManager with a profile.""" + self.profile = profile + + def _get_event_bus(self) -> EventBus: + """Get the event bus from the profile.""" + return self.profile.inject(EventBus) + + def _build_event_name(self, record_id: str) -> str: + """Build the event name for a given record ID.""" + return f"{WITNESS_EVENT_PREFIX}{record_id}" + + async def fire_pending_event( + self, record_id: str, document: dict, document_type: str = "log_entry" + ): + """Fire a pending witness event. + + Args: + record_id: The unique identifier for this witness request + document: The document (log entry or attested resource) awaiting witness + document_type: Type of document ("log_entry" or "attested_resource") + """ + event_bus = self._get_event_bus() + await event_bus.notify( + self.profile, + Event( + self._build_event_name(record_id), + { + "document": document, + "metadata": { + "state": WitnessingState.PENDING.value, + "document_type": document_type, + }, + }, + ), + ) + LOGGER.debug( + "Fired pending witness event for record_id=%s, document_type=%s", + record_id, + document_type, + ) + + async def fire_attested_event( + self, + record_id: str, + document: dict, + witness_signature: Optional[dict] = None, + document_type: str = "log_entry", + ): + """Fire an attested witness event. + + Args: + record_id: The unique identifier for this witness request + document: The document (log entry or attested resource) that was attested + witness_signature: Optional witness signature/proof + document_type: Type of document ("log_entry" or "attested_resource") + """ + event_bus = self._get_event_bus() + await event_bus.notify( + self.profile, + Event( + self._build_event_name(record_id), + { + "document": document, + "witness_signature": witness_signature, + "metadata": { + "state": WitnessingState.ATTESTED.value, + "document_type": document_type, + }, + }, + ), + ) + LOGGER.debug( + "Fired attested witness event for record_id=%s, document_type=%s", + record_id, + document_type, + ) + + async def fire_post_attested_event(self, record_id: str, did: str): + """Fire a post-attested event after resolving the DID document. + + This event is fired after a DID operation has been completed and the + DID document has been resolved from the server. + + Args: + record_id: The unique identifier for this witness request + did: The DID that was resolved + """ + async with self.profile.session() as session: + resolver = session.inject(DIDResolver) + resolved_did_doc = ( + await resolver.resolve_with_metadata(self.profile, did) + ).serialize() + + event_bus = self._get_event_bus() + metadata = resolved_did_doc["metadata"] + metadata["state"] = WitnessingState.ATTESTED.value + + await event_bus.notify( + self.profile, + Event( + self._build_event_name(record_id), + { + "document": resolved_did_doc["did_document"], + "metadata": metadata, + }, + ), + ) + LOGGER.debug( + "Fired post-attested witness event for record_id=%s, did=%s", + record_id, + did, + ) + + def get_event_pattern(self, record_id: str) -> str: + """Get the event pattern for waiting on a specific record ID. + + Args: + record_id: The unique identifier for the witness request + + Returns: + A regex pattern string for matching the event + """ + return rf"^{WITNESS_EVENT_PREFIX}{record_id}$" diff --git a/webvh/webvh/protocols/log_entry/handlers.py b/webvh/webvh/protocols/log_entry/handlers.py index 55b0019b5..30cc84a31 100644 --- a/webvh/webvh/protocols/log_entry/handlers.py +++ b/webvh/webvh/protocols/log_entry/handlers.py @@ -40,7 +40,9 @@ async def handle(self, context: RequestContext, responder: BaseResponder): config = await get_plugin_config(context.profile) connection_id = context.connection_record.connection_id if config.get("auto_attest", False): - witness_signature = await witness.sign_log_version(log_entry.get("versionId")) + witness_signature = await witness.sign( + {"versionId": log_entry.get("versionId")} + ) await responder.send( message=WitnessResponse( state=WitnessingState.ATTESTED.value, diff --git a/webvh/webvh/protocols/routes.py b/webvh/webvh/protocols/routes.py index 5c0746bf7..2eda831a2 100644 --- a/webvh/webvh/protocols/routes.py +++ b/webvh/webvh/protocols/routes.py @@ -53,7 +53,10 @@ async def get_pending_witness_requests(request: web.BaseRequest): try: record_type = WitnessRecordType(record_type_str) except ValueError: - raise WitnessError(f"Invalid record type: {record_type_str}. Must be one of: {[e.value for e in WitnessRecordType]}") + raise WitnessError( + f"Invalid record type: {record_type_str}. " + f"Must be one of: {[e.value for e in WitnessRecordType]}" + ) PENDING_RECORDS = RECORD_TYPES.get(record_type.value, None) if PENDING_RECORDS is None: raise WitnessError(f"Record type {record_type.value} not supported.") @@ -97,7 +100,10 @@ async def approve_pending_witness_request(request: web.BaseRequest): try: record_type = WitnessRecordType(record_type_str) except ValueError: - raise WitnessError(f"Invalid record type: {record_type_str}. Must be one of: {[e.value for e in WitnessRecordType]}") + raise WitnessError( + f"Invalid record type: {record_type_str}. " + f"Must be one of: {[e.value for e in WitnessRecordType]}" + ) PENDING_RECORDS = RECORD_TYPES.get(record_type.value, None) if PENDING_RECORDS is None: raise WitnessError(f"Record type {record_type.value} not supported.") @@ -119,9 +125,7 @@ async def approve_pending_witness_request(request: web.BaseRequest): await PENDING_RECORDS.remove_pending_record(context.profile, record_id) - LOGGER.info( - f"Witness successful for {record_type.value} record {record_id}" - ) + LOGGER.info(f"Witness successful for {record_type.value} record {record_id}") return web.json_response({"status": "success", "message": "Witness successful."}) except WitnessError as err: @@ -163,7 +167,10 @@ async def reject_pending_witness_request(request: web.BaseRequest): try: record_type = WitnessRecordType(record_type_str) except ValueError: - raise WitnessError(f"Invalid record type: {record_type_str}. Must be one of: {[e.value for e in WitnessRecordType]}") + raise WitnessError( + f"Invalid record type: {record_type_str}. " + f"Must be one of: {[e.value for e in WitnessRecordType]}" + ) PENDING_RECORDS = RECORD_TYPES.get(record_type.value, None) if PENDING_RECORDS is None: raise WitnessError(f"Record type {record_type.value} not supported.") diff --git a/webvh/webvh/protocols/states.py b/webvh/webvh/protocols/states.py index 84a2dbdc0..96c98db71 100644 --- a/webvh/webvh/protocols/states.py +++ b/webvh/webvh/protocols/states.py @@ -1,6 +1,18 @@ -"""States for witness protocols.""" +"""States and state handling for witness protocols.""" +import asyncio +import logging from enum import Enum +from typing import Optional, Callable, Awaitable, TYPE_CHECKING + +from acapy_agent.core.profile import Profile + +if TYPE_CHECKING: + from .events import WitnessEventManager + +LOGGER = logging.getLogger(__name__) + +WITNESS_WAIT_TIMEOUT_SECONDS = 2 class WitnessingState(Enum): @@ -11,3 +23,168 @@ class WitnessingState(Enum): ATTESTED = "attested" POSTED = "posted" FINISHED = "finished" + + +class WitnessingStateHandler: + """Handles witnessing state transitions using strategy pattern.""" + + def __init__(self, profile: Profile, event_manager: "WitnessEventManager"): + """Initialize the WitnessingStateHandler.""" + self.profile = profile + self.event_manager = event_manager + + async def handle_attested_state( + self, + record_id: str, + document: dict, + witness_signature: Optional[dict], + pending_record_manager, + document_type: str = "log_entry", + ) -> Optional[dict]: + """Handle ATTESTED state. + + Args: + record_id: The record ID + document: The document (log entry or attested resource) + witness_signature: Optional witness signature + pending_record_manager: Manager for pending records + document_type: Type of document ("log_entry" or "attested_resource") + + Returns: + None if record was removed, otherwise should continue processing + """ + await self.event_manager.fire_attested_event( + record_id, document, witness_signature, document_type=document_type + ) + + await asyncio.sleep(WITNESS_WAIT_TIMEOUT_SECONDS) + record_ids = await pending_record_manager.get_pending_record_ids(self.profile) + + if record_id is None or record_id not in record_ids: + return None + + await pending_record_manager.remove_pending_record_id(self.profile, record_id) + return {"continue": True} + + async def handle_pending_state( + self, + record_id: str, + document: dict, + document_type: str = "log_entry", + ) -> dict: + """Handle PENDING state. + + Args: + record_id: The record ID + document: The document (log entry or attested resource) + document_type: Type of document ("log_entry" or "attested_resource") + + Returns: + Dict indicating processing should stop + """ + await self.event_manager.fire_pending_event( + record_id, document, document_type=document_type + ) + return {"stop": True} + + async def handle_success_state( + self, + record_id: str, + document: dict, + did: str, + submit_handler: Callable[[dict, Optional[dict]], Awaitable[dict]], + witness_signature: Optional[dict] = None, + post_process_handler: Optional[Callable[[str], Awaitable]] = None, + ) -> dict: + """Handle SUCCESS/FINISHED state. + + Args: + record_id: The record ID + document: The document (log entry or attested resource) + did: The DID + submit_handler: Async function to submit the document (e.g., submit_log_entry) + witness_signature: Optional witness signature + post_process_handler: Optional async function for post-processing + (e.g., notify watchers) + + Returns: + Result from submit_handler + """ + # Submit the document + response = await submit_handler(document, witness_signature) + + # Post-process if handler provided + if post_process_handler: + await post_process_handler(did) + + # Fire post-attested event + await self.event_manager.fire_post_attested_event(record_id, did) + + return response + + async def process_state( + self, + state: str, + record_id: Optional[str], + document: dict, + witness_signature: Optional[dict], + pending_record_manager, + submit_handler: Callable[[dict, Optional[dict]], Awaitable[dict]], + document_type: str = "log_entry", + did: Optional[str] = None, + post_process_handler: Optional[Callable[[str], Awaitable]] = None, + ) -> Optional[dict]: + """Process a witnessing state using the appropriate handler. + + Args: + state: The witnessing state + record_id: Optional record ID + document: The document to process + witness_signature: Optional witness signature + pending_record_manager: Manager for pending records + submit_handler: Function to submit the document + document_type: Type of document ("log_entry" or "attested_resource") + did: Optional DID (extracted from document if not provided) + post_process_handler: Optional post-processing handler + + Returns: + Result from state handler, or None if processing should stop + """ + # Extract DID if not provided + if not did: + did = ( + document.get("state", {}).get("id") + or document.get("id", "").split("/")[0] + ) + + # Handle PENDING state (early return) + if state == WitnessingState.PENDING.value: + result = await self.handle_pending_state(record_id, document, document_type) + if result.get("stop"): + return None + + # Handle ATTESTED state + if state == WitnessingState.ATTESTED.value: + result = await self.handle_attested_state( + record_id, + document, + witness_signature, + pending_record_manager, + document_type, + ) + if result is None: + return None + + # Handle SUCCESS/FINISHED state (default) + if state in (WitnessingState.SUCCESS.value, WitnessingState.FINISHED.value): + return await self.handle_success_state( + record_id, + document, + did, + submit_handler, + witness_signature, + post_process_handler, + ) + + # For other states or fallback, just submit + return await submit_handler(document, witness_signature) diff --git a/webvh/webvh/routes.py b/webvh/webvh/routes.py index f7995218e..28d95bb94 100644 --- a/webvh/webvh/routes.py +++ b/webvh/webvh/routes.py @@ -8,14 +8,14 @@ from acapy_agent.core.profile import Profile from acapy_agent.core.util import STARTUP_EVENT_PATTERN from acapy_agent.resolver.routes import ResolutionResultSchema -from acapy_agent.storage.error import StorageNotFoundError +import re from acapy_agent.wallet.keys.manager import MultikeyManagerError from aiohttp import web from aiohttp_apispec import docs, querystring_schema, request_schema, response_schema -from marshmallow.exceptions import ValidationError from .config.config import get_global_plugin_config, get_plugin_config, set_config from .did.controller import ControllerManager +from .did.connection import WebVHConnectionManager from .did.exceptions import ( ConfigurationError, DidCreationError, @@ -41,6 +41,9 @@ LOGGER = logging.getLogger(__name__) +# Also log to root logger to ensure visibility +ROOT_LOGGER = logging.getLogger() + @docs(tags=["did-webvh"], summary="Get webvh plugin configuration") @tenant_authentication @@ -60,27 +63,28 @@ async def configure(request: web.BaseRequest): try: options = request_json config = await get_plugin_config(profile) - - config["server_url"] = options.get( - "server_url", config.get("server_url") - ).rstrip( "/") + + config["server_url"] = options.get("server_url", config.get("server_url")).rstrip( + "/" + ) if not config.get("server_url"): raise OperationError("No server url configured.") - + config["scids"] = config.get("scids", {}) config["witnesses"] = config.get("witnesses", []) config["endorsement"] = options.get("endorsement", False) config["auto_attest"] = options.get("auto_attest", False) config["parameter_options"] = options.get("parameter_options", {}) config["witness_id"] = options.get("witness_id", config.get("witness_id")) - + await set_config(profile, config) - - + config["witness"] = options.get("witness", False) if config["witness"]: - return web.json_response(await WitnessManager(profile).configure(config)) + return web.json_response( + await WitnessManager(profile).configure(config, log_message=False) + ) else: return web.json_response(await ControllerManager(profile).configure(config)) @@ -88,8 +92,6 @@ async def configure(request: web.BaseRequest): return web.json_response({"status": "error", "message": str(err)}) - - @docs(tags=["did-webvh"], summary="Create a did:webvh") @request_schema(WebvhCreateSchema) @response_schema(ResolutionResultSchema(), 200) @@ -213,33 +215,87 @@ async def update_whois(request: web.BaseRequest): return web.json_response({"status": "error", "message": str(err)}) +async def on_subwallet_created_event(profile: Profile, event: Event): + """Handle subwallet creation event in multitenant mode. + + This handler logs when a new subwallet is created but doesn't perform + any setup actions - subwallets need to be configured separately. + """ + wallet_id = event.payload.get("wallet_id") if event.payload else None + wallet_name = event.payload.get("wallet_name") if event.payload else None + + msg = ( + f"Subwallet created - wallet_id: {wallet_id}, " + f"wallet_name: {wallet_name}" + ) + LOGGER.info(msg) + ROOT_LOGGER.info(f"[WebVH] {msg}") + + if wallet_id: + msg2 = ( + f"Subwallet {wallet_id} created. " + "Configure WebVH settings via /did/webvh/configuration endpoint." + ) + LOGGER.info(msg2) + ROOT_LOGGER.info(f"[WebVH] {msg2}") + + def register_events(event_bus: EventBus): """Register to the acapy startup event.""" + # Use both loggers to ensure visibility + msg = "Registering WebVH startup event handler" LOGGER.warning("=" * 70) - LOGGER.warning("Registering WebVH startup event handler") + LOGGER.warning(msg) + ROOT_LOGGER.warning(f"[WebVH] {msg}") LOGGER.warning("=" * 70) event_bus.subscribe(STARTUP_EVENT_PATTERN, on_startup_event) - LOGGER.warning("WebVH startup event handler registered successfully") + msg2 = "WebVH startup event handler registered successfully" + LOGGER.warning(msg2) + ROOT_LOGGER.warning(f"[WebVH] {msg2}") + + # Subscribe to subwallet creation events + SUBWALLET_CREATED_PATTERN = re.compile( + "^acapy::multitenant::wallet::created::.*$" + ) + event_bus.subscribe(SUBWALLET_CREATED_PATTERN, on_subwallet_created_event) + msg3 = "WebVH subwallet creation event handler registered successfully" + LOGGER.info(msg3) + ROOT_LOGGER.info(f"[WebVH] {msg3}") async def on_startup_event(profile: Profile, event: Event): """Handle the plugin startup setup.""" + # Use both loggers to ensure visibility LOGGER.warning("=" * 70) - LOGGER.warning("WebVH startup event received") + ROOT_LOGGER.warning("[WebVH] " + "=" * 70) + msg = "WebVH startup event received" + LOGGER.warning(msg) + ROOT_LOGGER.warning(f"[WebVH] {msg}") LOGGER.warning("=" * 70) + ROOT_LOGGER.warning("[WebVH] " + "=" * 70) + config = get_global_plugin_config(profile) LOGGER.warning("WebVH global config: %s", config) - + ROOT_LOGGER.warning(f"[WebVH] global config: {config}") + if profile.settings.get("multitenant.enabled"): - LOGGER.warning("Skipping WebVH auto_config - multitenant enabled") + msg = "Skipping WebVH auto_config - multitenant enabled" + LOGGER.warning(msg) + ROOT_LOGGER.warning(f"[WebVH] {msg}") return - + if not config.get("auto_config", False): - LOGGER.warning("Skipping WebVH auto_config - auto_config not enabled in config") + msg = "Skipping WebVH auto_config - auto_config not enabled in config" + LOGGER.warning(msg) + ROOT_LOGGER.warning(f"[WebVH] {msg}") return - - LOGGER.warning("WebVH auto_config enabled, proceeding with setup") - LOGGER.warning("Witness mode: %s", config.get("witness", False)) + + msg = "WebVH auto_config enabled, proceeding with setup" + LOGGER.warning(msg) + ROOT_LOGGER.warning(f"[WebVH] {msg}") + msg2 = f"Witness mode: {config.get('witness', False)}" + LOGGER.warning(msg2) + ROOT_LOGGER.warning(f"[WebVH] {msg2}") # Remove auto_config from config before passing to setup methods # so it doesn't get persisted to stored config @@ -247,9 +303,13 @@ async def on_startup_event(profile: Profile, event: Event): config_without_auto = {k: v for k, v in config.items() if k != "auto_config"} if config.get("witness", False): - await WitnessManager(profile).auto_setup(config_without_auto) + await WitnessManager(profile).configure(config_without_auto, log_message=True) else: - await ControllerManager(profile).auto_setup(config_without_auto) + # Set up witness connection for controllers + witness_connection = WebVHConnectionManager(profile) + await witness_connection.setup( + config=config_without_auto, update_config=False, require_controller=True + ) # Save witness_id to stored config if it's in the global config if "witness_id" in config_without_auto: stored_config = await get_plugin_config(profile) diff --git a/webvh/webvh/tests/test_routes.py b/webvh/webvh/tests/test_routes.py index de4c5122b..ebbae5281 100644 --- a/webvh/webvh/tests/test_routes.py +++ b/webvh/webvh/tests/test_routes.py @@ -117,8 +117,8 @@ async def test_create(self): await create(self.request) - @mock.patch("webvh.routes.WitnessManager.auto_setup", new_callable=mock.AsyncMock) - @mock.patch("webvh.routes.ControllerManager.auto_setup", new_callable=mock.AsyncMock) + @mock.patch("webvh.routes.WitnessManager.configure", new_callable=mock.AsyncMock) + @mock.patch("webvh.routes.WebVHConnectionManager.setup", new_callable=mock.AsyncMock) async def test_on_startup_event_skips_when_auto_setup_disabled( self, mock_controller_auto, mock_witness_auto ): @@ -131,10 +131,10 @@ async def test_on_startup_event_skips_when_auto_setup_disabled( assert mock_controller_auto.await_count == 0 assert mock_witness_auto.await_count == 0 - @mock.patch("webvh.routes.WitnessManager.auto_setup", new_callable=mock.AsyncMock) - @mock.patch("webvh.routes.ControllerManager.auto_setup", new_callable=mock.AsyncMock) + @mock.patch("webvh.routes.WitnessManager.configure", new_callable=mock.AsyncMock) + @mock.patch("webvh.routes.WebVHConnectionManager.setup", new_callable=mock.AsyncMock) async def test_on_startup_event_runs_controller_auto_setup( - self, mock_controller_auto, mock_witness_auto + self, mock_controller_setup, mock_witness_auto ): self.profile.settings.set_value("multitenant.enabled", False) self.profile.settings.set_value( @@ -142,11 +142,11 @@ async def test_on_startup_event_runs_controller_auto_setup( {"webvh": {"server_url": TEST_SERVER_URL, "auto_config": True}}, ) await on_startup_event(self.profile, mock.MagicMock()) - mock_controller_auto.assert_awaited_once() + mock_controller_setup.assert_awaited_once() assert mock_witness_auto.await_count == 0 - @mock.patch("webvh.routes.WitnessManager.auto_setup", new_callable=mock.AsyncMock) - @mock.patch("webvh.routes.ControllerManager.auto_setup", new_callable=mock.AsyncMock) + @mock.patch("webvh.routes.WitnessManager.configure", new_callable=mock.AsyncMock) + @mock.patch("webvh.routes.WebVHConnectionManager.setup", new_callable=mock.AsyncMock) async def test_on_startup_event_runs_witness_auto_setup( self, mock_controller_auto, mock_witness_auto ): From b2d175d410d0ce96854075b65d66217cd3ca1a76 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Wed, 26 Nov 2025 17:10:57 -0500 Subject: [PATCH 07/21] refactore startup events and managers Signed-off-by: Patrick St-Louis --- webvh/webvh/did/connection.py | 202 +++++------------- webvh/webvh/did/controller.py | 39 ++-- .../did/tests/test_controller_manager.py | 4 + webvh/webvh/did/tests/test_witness_manager.py | 111 +--------- webvh/webvh/did/witness.py | 121 +++++++---- webvh/webvh/routes.py | 133 +++++------- webvh/webvh/tests/test_routes.py | 31 ++- 7 files changed, 235 insertions(+), 406 deletions(-) diff --git a/webvh/webvh/did/connection.py b/webvh/webvh/did/connection.py index b0d169cb5..a6ba0bfa9 100644 --- a/webvh/webvh/did/connection.py +++ b/webvh/webvh/did/connection.py @@ -15,14 +15,11 @@ from ..config.config import ( get_plugin_config, - get_server_domain, get_server_url, - is_controller, - set_config, ) from .exceptions import ConfigurationError, OperationError, WitnessError from .server_client import WebVHServerClient -from .utils import create_alias, url_to_domain +from .utils import parse_did_key LOGGER = logging.getLogger(__name__) @@ -37,46 +34,43 @@ def __init__(self, profile: Profile): """Initialize the WebVHConnectionManager with a profile.""" self.profile = profile - def _get_connection_alias(self) -> str: - """Get the witness connection alias for this controller. - - Returns: - The connection alias string (e.g., "webvh:example.com@witness") - """ - # This will be called after server_url is configured, - # so we can get it synchronously - # For async operations, use get_active_connection() which handles this properly - try: - # Try to get server_url from settings synchronously - config = self.profile.settings.get("plugin_config", {}).get("webvh", {}) - server_url = config.get("server_url") - if server_url: - domain = url_to_domain(server_url) - return create_alias(domain, "witnessConnection") - except Exception: - pass - # Fallback - will be resolved async in get_active_connection - return "webvh:unknown@witness" - async def get_active_connection( - self, auto_connect: bool = False + self, + server_url: str = None, + witness_id: str = None, ) -> Optional[ConnRecord]: """Get an active witness connection if one exists. Args: - auto_connect: If True, attempt to establish connection - if not already connected + server_url: Server URL to get connection for. + If not provided, will be fetched from config. + witness_id: Optional witness_id to get connection for. + If not provided, will be fetched from config. Returns: An active ConnRecord if found, None otherwise """ - try: - server_url = await get_server_url(self.profile) - except ConfigurationError: - # No server_url configured yet, so no active connection possible - return None + # Get server_url if not provided + if server_url is None: + try: + server_url = await get_server_url(self.profile) + except ConfigurationError: + # No server_url configured yet, so no active connection possible + return None + + # Get witness_id if not provided + if witness_id is None: + config = await get_plugin_config(self.profile) + witness_id = config.get("witness_id") + + # witness_id is required + if not witness_id: + raise ConfigurationError("witness_id is required for witness connections") - witness_alias = create_alias(url_to_domain(server_url), "witnessConnection") + # Build alias with witness_id + parsed_key = parse_did_key(witness_id) + witness_key = parsed_key.key + witness_alias = f"{server_url}@{witness_key}" async with self.profile.session() as session: connection_records = await ConnRecord.retrieve_by_alias( session, witness_alias @@ -89,27 +83,11 @@ async def get_active_connection( if len(active_connections) > 0: return active_connections[0] - # Attempt to connect if requested and no active connection found - if auto_connect: - try: - await self.connect() - # Try again after connecting - async with self.profile.session() as session: - connection_records = await ConnRecord.retrieve_by_alias( - session, witness_alias - ) - active_connections = [ - conn for conn in connection_records if conn.state == "active" - ] - if len(active_connections) > 0: - return active_connections[0] - except (ConfigurationError, OperationError) as err: - LOGGER.debug("Failed to auto-connect to witness: %s", err) - return None async def connect( self, + server_url: str = None, witness_id: str = None, wait_for_connection: bool = True, max_retries: int = CONNECTION_WAIT_RETRIES, @@ -118,7 +96,7 @@ async def connect( """Connect to a witness service. This method: - 1. Gets witness_id from config if not provided + 1. Gets server_url and witness_id from config if not provided 2. Fetches the witness invitation from the server 3. Validates the invitation 4. Checks if already connected @@ -126,6 +104,7 @@ async def connect( 6. Optionally waits for the connection to become active Args: + server_url: Optional server URL. If not provided, will be fetched from config witness_id: Optional DID of the witness service (e.g., "did:key:...") If not provided, will be fetched from config wait_for_connection: If True, wait for connection to become active @@ -138,8 +117,12 @@ async def connect( Raises: OperationError: If witness is not found, invitation is invalid, or connection fails - ConfigurationError: If witness_id is not configured + ConfigurationError: If server_url or witness_id is not configured """ + # Get server_url from config if not provided + if server_url is None: + server_url = await get_server_url(self.profile) + # Get witness_id from config if not provided if witness_id is None: config = await get_plugin_config(self.profile) @@ -164,14 +147,18 @@ async def connect( raise OperationError("Wrong invitation goal must match witness id.") # Check if already connected - if await self.get_active_connection(): + if await self.get_active_connection( + server_url=server_url, witness_id=witness_id + ): LOGGER.info("Connected to witness from previous connection.") return witness_id # Receive invitation and establish connection try: - server_domain = await get_server_domain(self.profile) - witness_alias = f"webvh:{server_domain}@witness" + # Extract key from witness_id for alias + parsed_key = parse_did_key(witness_id) + witness_key = parsed_key.key + witness_alias = f"{server_url}@{witness_key}" await OutOfBandManager(self.profile).receive_invitation( invitation=invitation, auto_accept=True, @@ -183,7 +170,9 @@ async def connect( # Wait for connection to become active (if requested) if wait_for_connection: for attempt in range(max_retries): - if await self.get_active_connection(): + if await self.get_active_connection( + server_url=server_url, witness_id=witness_id + ): LOGGER.info("Connected to witness agent.") return witness_id await asyncio.sleep(retry_interval) @@ -196,68 +185,9 @@ async def connect( return witness_id - async def setup( - self, - config: dict = None, - update_config: bool = False, - require_controller: bool = True, - ) -> Optional[str]: - """Set up witness connection with unified logic. - - Args: - config: Optional configuration dict. If not provided, - will be fetched from profile. - update_config: If True, update config to add witness_id to witnesses list - require_controller: If True, only proceed if agent is configured as controller - - Returns: - The witness_id that was connected to, or None if setup was skipped - - Raises: - ConfigurationError: If no server_url is configured - """ - # Check if controller (if required) - if require_controller and not await is_controller(self.profile): - return None - - # Get configuration - if config is None: - config = await get_plugin_config(self.profile) - - if not config.get("server_url"): - if require_controller: - raise ConfigurationError("No server url configured.") - return None - - witness_id = config.get("witness_id") - - if not witness_id: - LOGGER.info("No witness identifier, can't create connection automatically.") - return None - - # Check if already connected - already_connected = await self.get_active_connection() - if already_connected: - LOGGER.info("Connected to witness from previous connection.") - else: - # Attempt to connect - try: - await self.connect(witness_id) - except OperationError as err: - LOGGER.info("Witness connection setup failed: %s", err) - return None - - # Update config if requested (regardless of connection status) - if update_config: - if witness_id not in config.get("witnesses", []): - config.setdefault("witnesses", []).append(witness_id) - await set_config(self.profile, config) - - return witness_id - async def create_witness_invitation( self, - witness_key: str, + witness_id: str, alias: str = None, label: str = None, multi_use: bool = False, @@ -265,7 +195,7 @@ async def create_witness_invitation( """Create a witness invitation for controllers to connect. Args: - witness_key: The witness key multikey + witness_id: The witness DID (e.g., "did:key:...") alias: Optional alias for the invitation label: Optional label for the witness service multi_use: Whether the invitation can be used multiple times @@ -285,43 +215,9 @@ async def create_witness_invitation( alias=alias, my_label=label, goal_code="witness-service", - goal=f"did:key:{witness_key}", + goal=witness_id, multi_use=multi_use, ) return invi_rec.serialize() except OutOfBandManagerError as e: raise WitnessError(e) - - @staticmethod - def extract_invitation_url(invitation_record) -> str: - """Extract invitation URL from invitation record. - - Args: - invitation_record: Invitation record (dict or object) - - Returns: - The invitation URL, or None if not found - """ - if isinstance(invitation_record, dict): - return invitation_record.get("invitation_url") - return getattr(invitation_record, "invitation_url", None) - - @staticmethod - def format_invitation_url(invitation_url: str) -> str: - """Convert HTTP invitation URL to didcomm:// format if needed. - - Args: - invitation_url: The invitation URL (may be HTTP or didcomm format) - - Returns: - The formatted invitation URL (didcomm:// format if HTTP, otherwise unchanged) - """ - if invitation_url and invitation_url.startswith("http"): - from urllib.parse import urlparse, parse_qs - - parsed = urlparse(invitation_url) - query = parse_qs(parsed.query) - if "oob" in query: - oob_param = query["oob"][0] - return f"didcomm://?oob={oob_param}" - return invitation_url diff --git a/webvh/webvh/did/controller.py b/webvh/webvh/did/controller.py index 7eeb62da0..9045b53ca 100644 --- a/webvh/webvh/did/controller.py +++ b/webvh/webvh/did/controller.py @@ -6,7 +6,6 @@ import re from uuid import uuid4 from typing import Callable, Awaitable -import uuid from acapy_agent.core.event_bus import EventBus from acapy_agent.core.profile import Profile @@ -28,20 +27,24 @@ from ..protocols.attested_resource.record import PendingAttestedResourceRecord from ..protocols.log_entry.record import PendingLogEntryRecord from ..protocols.states import WitnessingState, WitnessingStateHandler -from .witness import WitnessManager from ..protocols.events import WitnessEventManager from .connection import WebVHConnectionManager -from .utils import parse_webvh +from .exceptions import ( + ConfigurationError, + DidCreationError, + OperationError, +) from .key_chain import KeyChainManager from .parameters import ParameterResolver -from .exceptions import DidCreationError from .server_client import WebVHServerClient, WebVHWatcherClient from .utils import ( - multikey_to_jwk, add_proof, - verify_proof, + multikey_to_jwk, + parse_webvh, validate_did, + verify_proof, ) +from .witness import WitnessManager LOGGER = logging.getLogger(__name__) @@ -242,12 +245,24 @@ async def handler(event_payload, record_id): async def configure(self, config: dict) -> dict: """Configure did controller. - This method only stores the configuration. Witness connection setup - is handled separately via WebVHConnectionManager.setup() or can be - done lazily when needed. + This sets up the witness connection if witness_id is configured. """ - # Configuration is already stored by the route handler - # No need to establish witness connection here + # Set up witness connection if witness_id is provided + witness_id = config.get("witness_id") + server_url = config.get("server_url") + if witness_id and server_url: + # Check if already connected + connection = await self.witness_connection.get_active_connection( + server_url=server_url, witness_id=witness_id + ) + if not connection: + try: + await self.witness_connection.connect( + server_url=server_url, witness_id=witness_id + ) + except (ConfigurationError, OperationError): + # Connection setup failed, but don't fail configuration + pass return config async def create(self, options: dict): @@ -348,7 +363,7 @@ async def streamline_did_operation(self, log_entry): ) witness_signature = None if document_state.witness_rule: - witness_request_id = str(uuid.uuid4()) + witness_request_id = str(uuid4()) witness_signature = await self.witness.witness_log_entry( parsed.scid, log_entry, witness_request_id ) diff --git a/webvh/webvh/did/tests/test_controller_manager.py b/webvh/webvh/did/tests/test_controller_manager.py index 933f01ef4..5b45727e1 100644 --- a/webvh/webvh/did/tests/test_controller_manager.py +++ b/webvh/webvh/did/tests/test_controller_manager.py @@ -197,6 +197,10 @@ async def mock_submit_log_entry_impl(*args, **kwargs): mock_submit_log_entry.side_effect = mock_submit_log_entry_impl + # Mock witness connection setup to avoid connection attempts + mock_setup = mock.AsyncMock(return_value=None) + self.controller.witness_connection.setup = mock_setup + # Configure witness key await self.controller.configure(config={"auto_attest": True, "witness": True}) diff --git a/webvh/webvh/did/tests/test_witness_manager.py b/webvh/webvh/did/tests/test_witness_manager.py index c2a4387d6..638f3e427 100644 --- a/webvh/webvh/did/tests/test_witness_manager.py +++ b/webvh/webvh/did/tests/test_witness_manager.py @@ -54,112 +54,7 @@ async def asyncSetUp(self): async def test_witness_key_alias(self): assert self.witness.key_alias - @mock.patch.object(WebVHConnectionManager, "get_active_connection") - async def test_auto_witness_setup_as_witness(self, mock_get_active_connection): - self.profile.settings.set_value( - "plugin_config", - {"webvh": {"witness": True, "server_url": SERVER_URL}}, - ) - await self.controller.witness_connection.setup( - update_config=False, require_controller=True - ) - assert not mock_get_active_connection.called - - async def test_auto_witness_setup_as_controller_no_server_url(self): - self.profile.settings.set_value( - "plugin_config", - {"webvh": {"witness": False}}, - ) - with self.assertRaises(ConfigurationError): - await self.controller.witness_connection.setup( - update_config=False, require_controller=True - ) - - async def test_auto_witness_setup_as_controller_with_previous_connection(self): - self.profile.settings.set_value( - "plugin_config", - { - "webvh": { - "witness": False, - "server_url": SERVER_URL, - } - }, - ) - async with self.profile.session() as session: - record = ConnRecord( - alias=f"{SERVER_URL}@Witness", - state="active", - ) - await record.save(session) - await self.controller.witness_connection.setup( - update_config=False, require_controller=True - ) - - async def test_auto_witness_setup_as_controller_no_witness_invitation(self): - self.profile.settings.set_value( - "plugin_config", - { - "webvh": { - "witness": False, - "server_url": SERVER_URL, - } - }, - ) - await self.controller.witness_connection.setup( - update_config=False, require_controller=True - ) - - @mock.patch.object(OutOfBandManager, "receive_invitation") - @mock.patch.object(asyncio, "sleep") - async def test_auto_witness_setup_as_controller_no_active_connection(self, *_): - self.profile.settings.set_value("plugin_config.webvh.witness", False) - self.profile.settings.set_value( - "plugin_config", - { - "webvh": { - "witness": False, - "server_url": SERVER_URL, - "witness_invitation": "http://witness:9050?oob=eyJAdHlwZSI6ICJodHRwczovL2RpZGNvbW0ub3JnL291dC1vZi1iYW5kLzEuMS9pbnZpdGF0aW9uIiwgIkBpZCI6ICIwZDkwMGVjMC0wYzE3LTRmMTYtOTg1ZC1mYzU5MzVlYThjYTkiLCAibGFiZWwiOiAidGR3LWVuZG9yc2VyIiwgImhhbmRzaGFrZV9wcm90b2NvbHMiOiBbImh0dHBzOi8vZGlkY29tbS5vcmcvZGlkZXhjaGFuZ2UvMS4wIl0sICJzZXJ2aWNlcyI6IFt7ImlkIjogIiNpbmxpbmUiLCAidHlwZSI6ICJkaWQtY29tbXVuaWNhdGlvbiIsICJyZWNpcGllbnRLZXlzIjogWyJkaWQ6a2V5Ono2TWt0bXJUQURBWWRlc2Ftb3F1ZVV4NHNWM0g1Mms5b2ZoQXZRZVFaUG9vdTE3ZSN6Nk1rdG1yVEFEQVlkZXNhbW9xdWVVeDRzVjNINTJrOW9maEF2UWVRWlBvb3UxN2UiXSwgInNlcnZpY2VFbmRwb2ludCI6ICJodHRwOi8vbG9jYWxob3N0OjkwNTAifV19", - } - }, - ) - self.profile.context.injector.bind_instance( - RouteManager, mock.AsyncMock(RouteManager, autospec=True) - ) - await self.controller.witness_connection.setup( - update_config=False, require_controller=True - ) - - @mock.patch.object(OutOfBandManager, "receive_invitation") - async def test_auto_witness_setup_as_controller_conn_becomes_active(self, *_): - self.profile.settings.set_value("plugin_config.webvh.witness", False) - self.profile.settings.set_value( - "plugin_config", - { - "webvh": { - "witness": False, - "server_url": SERVER_URL, - "witness_invitation": "http://witness:9050?oob=eyJAdHlwZSI6ICJodHRwczovL2RpZGNvbW0ub3JnL291dC1vZi1iYW5kLzEuMS9pbnZpdGF0aW9uIiwgIkBpZCI6ICIwZDkwMGVjMC0wYzE3LTRmMTYtOTg1ZC1mYzU5MzVlYThjYTkiLCAibGFiZWwiOiAidGR3LWVuZG9yc2VyIiwgImhhbmRzaGFrZV9wcm90b2NvbHMiOiBbImh0dHBzOi8vZGlkY29tbS5vcmcvZGlkZXhjaGFuZ2UvMS4wIl0sICJzZXJ2aWNlcyI6IFt7ImlkIjogIiNpbmxpbmUiLCAidHlwZSI6ICJkaWQtY29tbXVuaWNhdGlvbiIsICJyZWNpcGllbnRLZXlzIjogWyJkaWQ6a2V5Ono2TWt0bXJUQURBWWRlc2Ftb3F1ZVV4NHNWM0g1Mms5b2ZoQXZRZVFaUG9vdTE3ZSN6Nk1rdG1yVEFEQVlkZXNhbW9xdWVVeDRzVjNINTJrOW9maEF2UWVRWlBvb3UxN2UiXSwgInNlcnZpY2VFbmRwb2ludCI6ICJodHRwOi8vbG9jYWxob3N0OjkwNTAifV19", - } - }, - ) - self.profile.context.injector.bind_instance( - RouteManager, mock.AsyncMock(RouteManager, autospec=True) - ) - - async def _create_connection(): - await asyncio.sleep(1) - async with self.profile.session() as session: - record = ConnRecord( - alias=f"{SERVER_URL}@Witness", - state="active", - ) - await record.save(session) - - asyncio.create_task(_create_connection()) - await self.controller.witness_connection.setup( - update_config=False, require_controller=True - ) + # Tests for setup() method removed - connection setup now happens in controller.configure() async def test_witness_auto_setup_skips_when_not_configured(self): self.profile.settings.set_value( @@ -171,7 +66,7 @@ async def test_witness_auto_setup_skips_when_not_configured(self): } }, ) - await self.witness.configure(log_message=True) + await self.witness.configure() config = await get_plugin_config(self.profile) assert "witnesses" not in config @@ -202,7 +97,7 @@ async def test_witness_auto_setup_creates_key_and_updates_config(self): "create_witness_invitation", new=mock.AsyncMock(return_value={"invitation_url": "https://example.com"}), ): - await witness.configure(log_message=True) + await witness.configure() config = await get_plugin_config(profile) assert config.get("witnesses") diff --git a/webvh/webvh/did/witness.py b/webvh/webvh/did/witness.py index d12427c9e..2ab29b369 100644 --- a/webvh/webvh/did/witness.py +++ b/webvh/webvh/did/witness.py @@ -23,7 +23,7 @@ ) from ..protocols.states import WitnessingState from ..did.server_client import WebVHServerClient -from ..did.utils import add_proof, url_to_domain, format_witness_ready_message +from ..did.utils import add_proof, url_to_domain from ..did.key_chain import KeyChainManager from ..did.connection import WebVHConnectionManager @@ -45,7 +45,7 @@ def __init__(self, profile: Profile): "proofPurpose": "assertionMethod", } - async def configure(self, config: dict = None, log_message: bool = False) -> dict: + async def configure(self, config: dict = None) -> dict: """Configure this agent as a witness. This creates the witness key, invitation, and updates the config. @@ -53,7 +53,6 @@ async def configure(self, config: dict = None, log_message: bool = False) -> dic Args: config: Configuration dict. If None, will be fetched from profile. - log_message: If True, log and print formatted witness ready message. Returns: Config dict with invitation_url (for API responses) @@ -62,59 +61,69 @@ async def configure(self, config: dict = None, log_message: bool = False) -> dic config = await get_plugin_config(self.profile) if not config.get("witness", False): - if log_message: - LOGGER.debug("Skipping witness configuration - witness not enabled") return config + # Set up witness key and get witness_id + config["witness_id"] = await self.key_setup(config.get("witness_id")) + config.setdefault("witnesses", []) - key_alias = self.key_alias + if config["witness_id"] not in config["witnesses"]: + config["witnesses"].append(config["witness_id"]) - # If witness_id is provided, try to use that key - if witness_id := config.get("witness_id", None): - parsed_key = parse_did_key(witness_id) - witness_key = parsed_key.key - await self.key_chain.bind_key(witness_key, key_alias) - if log_message: - LOGGER.info("Using configured witness_id: %s", witness_id) - else: - # Otherwise, find existing key or create new one - witness_key = await self.key_chain.find_key(key_alias) + # Create witness invitation + config["invitation_url"] = await self.invitation_setup(config["witness_id"]) + + await set_config(self.profile, config) + + return config + + async def key_setup(self, witness_id: str = None) -> str: + """Set up witness key and return witness_id. + + If witness_id is provided, binds that key. + Otherwise, finds existing key or creates a new one. + + Args: + witness_id: Optional witness DID (e.g., "did:key:...") + + Returns: + The witness_id (did:key format) + """ + # If witness_id is not provided, find existing key or create new one + if not witness_id: + witness_key = await self.key_chain.find_key(self.key_alias) if not witness_key: - LOGGER.info("Creating witness key for alias %s", key_alias) - witness_key = await self.key_chain.create_key(key_alias) + LOGGER.info("Creating witness key for alias %s", self.key_alias) + witness_key = await self.key_chain.create_key(self.key_alias) witness_id = f"did:key:{witness_key}" + else: + # If witness_id is provided, bind that key + parsed_key = parse_did_key(witness_id) + witness_key = parsed_key.key + await self.key_chain.bind_key(witness_key, self.key_alias) + return witness_id - # Store witness_id in config if not already set - if not config.get("witness_id"): - config["witness_id"] = witness_id - await set_config(self.profile, config) + async def invitation_setup(self, witness_id: str) -> str: + """Create witness invitation and return invitation URL. - if witness_id not in config["witnesses"]: - config["witnesses"].append(witness_id) - await set_config(self.profile, config) + Args: + witness_id: The witness DID (e.g., "did:key:...") - # Create witness invitation + Returns: + The invitation URL string + """ invitation_record = await self.witness_connection.create_witness_invitation( - witness_key=witness_key, + witness_id=witness_id, alias=None, label="Witness Service", multi_use=True, ) - invitation_url = self.witness_connection.extract_invitation_url(invitation_record) - invitation_url = self.witness_connection.format_invitation_url(invitation_url) - - # Log and print formatted message if requested (for startup visibility) - if log_message: - message = format_witness_ready_message(witness_id, invitation_url) - # Log each line separately for better log parsing - for line in message.strip().split("\n"): - LOGGER.warning(line) - print(message, end="") - - # Return config with invitation_url for API response (but don't persist it) - response_config = config.copy() - response_config["invitation_url"] = invitation_url - return response_config + invitation_url = ( + invitation_record.get("invitation_url") + if isinstance(invitation_record, dict) + else getattr(invitation_record, "invitation_url", None) + ) + return invitation_url @property def key_alias(self) -> str: @@ -168,9 +177,21 @@ async def witness_log_entry( else: responder = self.profile.inject(BaseResponder) + config = await get_plugin_config(self.profile) + server_url = config.get("server_url") witness_connection = await self.witness_connection.get_active_connection( - auto_connect=True + server_url=server_url ) + if not witness_connection: + # Attempt to connect if no active connection found + config = await get_plugin_config(self.profile) + witness_id = config.get("witness_id") + await self.witness_connection.connect( + server_url=server_url, witness_id=witness_id + ) + witness_connection = await self.witness_connection.get_active_connection( + server_url=server_url + ) if not witness_connection: raise WitnessError("No active witness connection found.") @@ -206,9 +227,21 @@ async def witness_attested_resource( else: responder = self.profile.inject(BaseResponder) + config = await get_plugin_config(self.profile) + server_url = config.get("server_url") witness_connection = await self.witness_connection.get_active_connection( - auto_connect=True + server_url=server_url ) + if not witness_connection: + # Attempt to connect if no active connection found + config = await get_plugin_config(self.profile) + witness_id = config.get("witness_id") + await self.witness_connection.connect( + server_url=server_url, witness_id=witness_id + ) + witness_connection = await self.witness_connection.get_active_connection( + server_url=server_url + ) if not witness_connection: raise WitnessError("No active witness connection found.") diff --git a/webvh/webvh/routes.py b/webvh/webvh/routes.py index 28d95bb94..c3394e0f0 100644 --- a/webvh/webvh/routes.py +++ b/webvh/webvh/routes.py @@ -13,9 +13,12 @@ from aiohttp import web from aiohttp_apispec import docs, querystring_schema, request_schema, response_schema -from .config.config import get_global_plugin_config, get_plugin_config, set_config +from .config.config import ( + get_global_plugin_config, + get_plugin_config, + set_config, +) from .did.controller import ControllerManager -from .did.connection import WebVHConnectionManager from .did.exceptions import ( ConfigurationError, DidCreationError, @@ -32,6 +35,7 @@ WebvhSCIDQueryStringSchema, WebvhUpdateWhoisSchema, ) +from .did.utils import format_witness_ready_message from .did.witness import WitnessManager from .protocols.routes import ( get_pending_witness_requests, @@ -64,29 +68,27 @@ async def configure(request: web.BaseRequest): options = request_json config = await get_plugin_config(profile) - config["server_url"] = options.get("server_url", config.get("server_url")).rstrip( - "/" - ) - - if not config.get("server_url"): + if not (server_url := options.get("server_url", config.get("server_url", None))): raise OperationError("No server url configured.") + config["witness"] = options.get("witness", False) + config["witness_id"] = options.get("witness_id", config.get("witness_id")) + config["server_url"] = server_url.rstrip("/") + config["scids"] = config.get("scids", {}) config["witnesses"] = config.get("witnesses", []) config["endorsement"] = options.get("endorsement", False) config["auto_attest"] = options.get("auto_attest", False) + config["parameter_options"] = options.get("parameter_options", {}) - config["witness_id"] = options.get("witness_id", config.get("witness_id")) await set_config(profile, config) - config["witness"] = options.get("witness", False) if config["witness"]: - return web.json_response( - await WitnessManager(profile).configure(config, log_message=False) - ) + manager = WitnessManager(profile) else: - return web.json_response(await ControllerManager(profile).configure(config)) + manager = ControllerManager(profile) + return web.json_response(await manager.configure(config)) except (ConfigurationError, OperationError) as err: return web.json_response({"status": "error", "message": str(err)}) @@ -215,31 +217,6 @@ async def update_whois(request: web.BaseRequest): return web.json_response({"status": "error", "message": str(err)}) -async def on_subwallet_created_event(profile: Profile, event: Event): - """Handle subwallet creation event in multitenant mode. - - This handler logs when a new subwallet is created but doesn't perform - any setup actions - subwallets need to be configured separately. - """ - wallet_id = event.payload.get("wallet_id") if event.payload else None - wallet_name = event.payload.get("wallet_name") if event.payload else None - - msg = ( - f"Subwallet created - wallet_id: {wallet_id}, " - f"wallet_name: {wallet_name}" - ) - LOGGER.info(msg) - ROOT_LOGGER.info(f"[WebVH] {msg}") - - if wallet_id: - msg2 = ( - f"Subwallet {wallet_id} created. " - "Configure WebVH settings via /did/webvh/configuration endpoint." - ) - LOGGER.info(msg2) - ROOT_LOGGER.info(f"[WebVH] {msg2}") - - def register_events(event_bus: EventBus): """Register to the acapy startup event.""" # Use both loggers to ensure visibility @@ -265,56 +242,54 @@ def register_events(event_bus: EventBus): async def on_startup_event(profile: Profile, event: Event): """Handle the plugin startup setup.""" - # Use both loggers to ensure visibility - LOGGER.warning("=" * 70) - ROOT_LOGGER.warning("[WebVH] " + "=" * 70) - msg = "WebVH startup event received" - LOGGER.warning(msg) - ROOT_LOGGER.warning(f"[WebVH] {msg}") - LOGGER.warning("=" * 70) - ROOT_LOGGER.warning("[WebVH] " + "=" * 70) config = get_global_plugin_config(profile) - LOGGER.warning("WebVH global config: %s", config) - ROOT_LOGGER.warning(f"[WebVH] global config: {config}") - + + # Skip if multitenant enabled or auto_config disabled if profile.settings.get("multitenant.enabled"): - msg = "Skipping WebVH auto_config - multitenant enabled" - LOGGER.warning(msg) - ROOT_LOGGER.warning(f"[WebVH] {msg}") return - - if not config.get("auto_config", False): - msg = "Skipping WebVH auto_config - auto_config not enabled in config" - LOGGER.warning(msg) - ROOT_LOGGER.warning(f"[WebVH] {msg}") + + if not config.pop("auto_config", False): return - msg = "WebVH auto_config enabled, proceeding with setup" - LOGGER.warning(msg) - ROOT_LOGGER.warning(f"[WebVH] {msg}") - msg2 = f"Witness mode: {config.get('witness', False)}" - LOGGER.warning(msg2) - ROOT_LOGGER.warning(f"[WebVH] {msg2}") - - # Remove auto_config from config before passing to setup methods - # so it doesn't get persisted to stored config - # All other config values (including witness_id) are preserved - config_without_auto = {k: v for k, v in config.items() if k != "auto_config"} - if config.get("witness", False): - await WitnessManager(profile).configure(config_without_auto, log_message=True) + # Configure witness and print information + witness_config = await WitnessManager(profile).configure(config) + message = format_witness_ready_message( + witness_config["witness_id"], witness_config.get("invitation_url") + ) + for line in message.strip().split("\n"): + LOGGER.warning(line) + ROOT_LOGGER.warning(f"[WebVH] {line}") + print(message, end="") else: - # Set up witness connection for controllers - witness_connection = WebVHConnectionManager(profile) - await witness_connection.setup( - config=config_without_auto, update_config=False, require_controller=True + # Configure controller (sets up witness connection if witness_id is configured) + await ControllerManager(profile).configure(config) + + +async def on_subwallet_created_event(profile: Profile, event: Event): + """Handle subwallet creation event in multitenant mode. + + This handler logs when a new subwallet is created but doesn't perform + any setup actions - subwallets need to be configured separately. + """ + wallet_id = event.payload.get("wallet_id") if event.payload else None + wallet_name = event.payload.get("wallet_name") if event.payload else None + + msg = ( + f"Subwallet created - wallet_id: {wallet_id}, " + f"wallet_name: {wallet_name}" + ) + LOGGER.info(msg) + ROOT_LOGGER.info(f"[WebVH] {msg}") + + if wallet_id: + msg2 = ( + f"Subwallet {wallet_id} created. " + "Configure WebVH settings via /did/webvh/configuration endpoint." ) - # Save witness_id to stored config if it's in the global config - if "witness_id" in config_without_auto: - stored_config = await get_plugin_config(profile) - stored_config["witness_id"] = config_without_auto["witness_id"] - await set_config(profile, stored_config) + LOGGER.info(msg2) + ROOT_LOGGER.info(f"[WebVH] {msg2}") async def register(app: web.Application): diff --git a/webvh/webvh/tests/test_routes.py b/webvh/webvh/tests/test_routes.py index ebbae5281..b0ba72f10 100644 --- a/webvh/webvh/tests/test_routes.py +++ b/webvh/webvh/tests/test_routes.py @@ -118,9 +118,9 @@ async def test_create(self): await create(self.request) @mock.patch("webvh.routes.WitnessManager.configure", new_callable=mock.AsyncMock) - @mock.patch("webvh.routes.WebVHConnectionManager.setup", new_callable=mock.AsyncMock) + @mock.patch("webvh.routes.ControllerManager.configure", new_callable=mock.AsyncMock) async def test_on_startup_event_skips_when_auto_setup_disabled( - self, mock_controller_auto, mock_witness_auto + self, mock_controller_configure, mock_witness_auto ): self.profile.settings.set_value("multitenant.enabled", False) self.profile.settings.set_value( @@ -128,27 +128,33 @@ async def test_on_startup_event_skips_when_auto_setup_disabled( {"webvh": {"server_url": TEST_SERVER_URL, "auto_config": False}}, ) await on_startup_event(self.profile, mock.MagicMock()) - assert mock_controller_auto.await_count == 0 + assert mock_controller_configure.await_count == 0 assert mock_witness_auto.await_count == 0 @mock.patch("webvh.routes.WitnessManager.configure", new_callable=mock.AsyncMock) - @mock.patch("webvh.routes.WebVHConnectionManager.setup", new_callable=mock.AsyncMock) + @mock.patch("webvh.routes.ControllerManager.configure", new_callable=mock.AsyncMock) async def test_on_startup_event_runs_controller_auto_setup( - self, mock_controller_setup, mock_witness_auto + self, mock_controller_configure, mock_witness_auto ): self.profile.settings.set_value("multitenant.enabled", False) self.profile.settings.set_value( "plugin_config", - {"webvh": {"server_url": TEST_SERVER_URL, "auto_config": True}}, + { + "webvh": { + "server_url": TEST_SERVER_URL, + "witness_id": TEST_WITNESS_INVITATION["goal"], + "auto_config": True, + } + }, ) await on_startup_event(self.profile, mock.MagicMock()) - mock_controller_setup.assert_awaited_once() + mock_controller_configure.assert_awaited_once() assert mock_witness_auto.await_count == 0 @mock.patch("webvh.routes.WitnessManager.configure", new_callable=mock.AsyncMock) - @mock.patch("webvh.routes.WebVHConnectionManager.setup", new_callable=mock.AsyncMock) + @mock.patch("webvh.routes.ControllerManager.configure", new_callable=mock.AsyncMock) async def test_on_startup_event_runs_witness_auto_setup( - self, mock_controller_auto, mock_witness_auto + self, mock_controller_configure, mock_witness_auto ): self.profile.settings.set_value("multitenant.enabled", False) self.profile.settings.set_value( @@ -161,6 +167,11 @@ async def test_on_startup_event_runs_witness_auto_setup( } }, ) + # Mock configure to return config with witness_id and invitation_url + mock_witness_auto.return_value = { + "witness_id": TEST_WITNESS_INVITATION["goal"], + "invitation_url": "https://example.com/invitation", + } await on_startup_event(self.profile, mock.MagicMock()) - assert mock_controller_auto.await_count == 0 + assert mock_controller_configure.await_count == 0 mock_witness_auto.assert_awaited_once() From 881957fbf8bbdd37883881961bd432c91db8933f Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Wed, 26 Nov 2025 17:17:23 -0500 Subject: [PATCH 08/21] fix server client response Signed-off-by: Patrick St-Louis --- webvh/webvh/did/connection.py | 4 +--- webvh/webvh/did/server_client.py | 5 ++--- webvh/webvh/did/tests/test_witness_manager.py | 4 ---- webvh/webvh/did/witness.py | 6 +++--- webvh/webvh/routes.py | 17 ++++++----------- 5 files changed, 12 insertions(+), 24 deletions(-) diff --git a/webvh/webvh/did/connection.py b/webvh/webvh/did/connection.py index a6ba0bfa9..e79e92679 100644 --- a/webvh/webvh/did/connection.py +++ b/webvh/webvh/did/connection.py @@ -147,9 +147,7 @@ async def connect( raise OperationError("Wrong invitation goal must match witness id.") # Check if already connected - if await self.get_active_connection( - server_url=server_url, witness_id=witness_id - ): + if await self.get_active_connection(server_url=server_url, witness_id=witness_id): LOGGER.info("Connected to witness from previous connection.") return witness_id diff --git a/webvh/webvh/did/server_client.py b/webvh/webvh/did/server_client.py index c22395110..a36bbed52 100644 --- a/webvh/webvh/did/server_client.py +++ b/webvh/webvh/did/server_client.py @@ -44,7 +44,7 @@ async def get_document(self): response = await session.get( f"{await get_server_url(self.profile)}/.well-known/did.json" ) - return response.json() + return await response.json() async def get_witness_services(self): """Get the witness services from the server document.""" @@ -72,8 +72,7 @@ async def get_witness_invitation(self, witness_id: str): async with ClientSession() as session: response = await session.get(invitation_url) - - return response.json() + return await response.json() async def request_identifier(self, namespace, identifier) -> tuple: """Contact the webvh server to request an identifier.""" diff --git a/webvh/webvh/did/tests/test_witness_manager.py b/webvh/webvh/did/tests/test_witness_manager.py index 638f3e427..cdb814337 100644 --- a/webvh/webvh/did/tests/test_witness_manager.py +++ b/webvh/webvh/did/tests/test_witness_manager.py @@ -1,16 +1,12 @@ -import asyncio from unittest import IsolatedAsyncioTestCase -from acapy_agent.connections.models.conn_record import ConnRecord from acapy_agent.messaging.responder import BaseResponder from acapy_agent.protocols.coordinate_mediation.v1_0.route_manager import RouteManager -from acapy_agent.protocols.out_of_band.v1_0.manager import OutOfBandManager from acapy_agent.tests import mock from acapy_agent.utils.testing import create_test_profile from acapy_agent.wallet.key_type import KeyTypes from acapy_agent.wallet.keys.manager import MultikeyManager -from ..exceptions import ConfigurationError from ..controller import ControllerManager from ..witness import WitnessManager from ..connection import WebVHConnectionManager diff --git a/webvh/webvh/did/witness.py b/webvh/webvh/did/witness.py index 2ab29b369..3ba08294d 100644 --- a/webvh/webvh/did/witness.py +++ b/webvh/webvh/did/witness.py @@ -65,16 +65,16 @@ async def configure(self, config: dict = None) -> dict: # Set up witness key and get witness_id config["witness_id"] = await self.key_setup(config.get("witness_id")) - + config.setdefault("witnesses", []) if config["witness_id"] not in config["witnesses"]: config["witnesses"].append(config["witness_id"]) # Create witness invitation config["invitation_url"] = await self.invitation_setup(config["witness_id"]) - + await set_config(self.profile, config) - + return config async def key_setup(self, witness_id: str = None) -> str: diff --git a/webvh/webvh/routes.py b/webvh/webvh/routes.py index c3394e0f0..cac5c4373 100644 --- a/webvh/webvh/routes.py +++ b/webvh/webvh/routes.py @@ -74,12 +74,12 @@ async def configure(request: web.BaseRequest): config["witness"] = options.get("witness", False) config["witness_id"] = options.get("witness_id", config.get("witness_id")) config["server_url"] = server_url.rstrip("/") - + config["scids"] = config.get("scids", {}) config["witnesses"] = config.get("witnesses", []) config["endorsement"] = options.get("endorsement", False) config["auto_attest"] = options.get("auto_attest", False) - + config["parameter_options"] = options.get("parameter_options", {}) await set_config(profile, config) @@ -231,9 +231,7 @@ def register_events(event_bus: EventBus): ROOT_LOGGER.warning(f"[WebVH] {msg2}") # Subscribe to subwallet creation events - SUBWALLET_CREATED_PATTERN = re.compile( - "^acapy::multitenant::wallet::created::.*$" - ) + SUBWALLET_CREATED_PATTERN = re.compile("^acapy::multitenant::wallet::created::.*$") event_bus.subscribe(SUBWALLET_CREATED_PATTERN, on_subwallet_created_event) msg3 = "WebVH subwallet creation event handler registered successfully" LOGGER.info(msg3) @@ -244,11 +242,11 @@ async def on_startup_event(profile: Profile, event: Event): """Handle the plugin startup setup.""" config = get_global_plugin_config(profile) - + # Skip if multitenant enabled or auto_config disabled if profile.settings.get("multitenant.enabled"): return - + if not config.pop("auto_config", False): return @@ -276,10 +274,7 @@ async def on_subwallet_created_event(profile: Profile, event: Event): wallet_id = event.payload.get("wallet_id") if event.payload else None wallet_name = event.payload.get("wallet_name") if event.payload else None - msg = ( - f"Subwallet created - wallet_id: {wallet_id}, " - f"wallet_name: {wallet_name}" - ) + msg = f"Subwallet created - wallet_id: {wallet_id}, wallet_name: {wallet_name}" LOGGER.info(msg) ROOT_LOGGER.info(f"[WebVH] {msg}") From c251e431f47ebac51fc1246ff3afb9fdbd854abd Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Wed, 26 Nov 2025 17:36:21 -0500 Subject: [PATCH 09/21] add logging Signed-off-by: Patrick St-Louis --- webvh/webvh/did/connection.py | 18 ++++++++++++- webvh/webvh/did/controller.py | 43 +++++++++++++++++++++++++++++--- webvh/webvh/did/server_client.py | 25 +++++++++++++++---- webvh/webvh/protocols/events.py | 1 + 4 files changed, 77 insertions(+), 10 deletions(-) diff --git a/webvh/webvh/did/connection.py b/webvh/webvh/did/connection.py index e79e92679..5e673a00a 100644 --- a/webvh/webvh/did/connection.py +++ b/webvh/webvh/did/connection.py @@ -71,6 +71,7 @@ async def get_active_connection( parsed_key = parse_did_key(witness_id) witness_key = parsed_key.key witness_alias = f"{server_url}@{witness_key}" + LOGGER.debug(f"Looking for active connection with alias: {witness_alias}") async with self.profile.session() as session: connection_records = await ConnRecord.retrieve_by_alias( session, witness_alias @@ -81,8 +82,13 @@ async def get_active_connection( ] if len(active_connections) > 0: + LOGGER.info( + f"Found active connection to witness {witness_id} " + f"(connection_id: {active_connections[0].connection_id})" + ) return active_connections[0] + LOGGER.debug(f"No active connection found for witness {witness_id}") return None async def connect( @@ -133,12 +139,19 @@ async def connect( ) # Fetch invitation from server + LOGGER.info(f"Fetching witness invitation for {witness_id} from server {server_url}") server_client = WebVHServerClient(self.profile) - invitation = await server_client.get_witness_invitation(witness_id) + try: + invitation = await server_client.get_witness_invitation(witness_id) + except Exception as e: + LOGGER.error(f"Failed to fetch witness invitation: {e}") + raise if not invitation: raise OperationError(f"Witness {witness_id} not listed by server document.") + LOGGER.info(f"Received invitation: {invitation.get('@id', 'unknown id')}") + # Validate invitation if invitation.get("goal-code", None) != "witness-service": raise OperationError("Missing invitation goal-code and witness did.") @@ -157,12 +170,15 @@ async def connect( parsed_key = parse_did_key(witness_id) witness_key = parsed_key.key witness_alias = f"{server_url}@{witness_key}" + LOGGER.info(f"Receiving invitation with alias: {witness_alias}") await OutOfBandManager(self.profile).receive_invitation( invitation=invitation, auto_accept=True, alias=witness_alias, ) + LOGGER.info(f"Invitation received successfully, waiting for connection...") except BaseModelError as err: + LOGGER.error(f"Error receiving witness invitation: {err}") raise OperationError(f"Error receiving witness invitation: {err}") # Wait for connection to become active (if requested) diff --git a/webvh/webvh/did/controller.py b/webvh/webvh/did/controller.py index 9045b53ca..787e36e04 100644 --- a/webvh/webvh/did/controller.py +++ b/webvh/webvh/did/controller.py @@ -251,18 +251,53 @@ async def configure(self, config: dict) -> dict: witness_id = config.get("witness_id") server_url = config.get("server_url") if witness_id and server_url: + LOGGER.warning( + f"Configuring controller: attempting to connect to witness {witness_id} " + f"at server {server_url}" + ) # Check if already connected connection = await self.witness_connection.get_active_connection( server_url=server_url, witness_id=witness_id ) - if not connection: + if connection: + LOGGER.warning( + f"Already connected to witness {witness_id} " + f"(connection_id: {connection.connection_id})" + ) + else: try: - await self.witness_connection.connect( + LOGGER.warning(f"Connecting to witness {witness_id}...") + connected_witness_id = await self.witness_connection.connect( server_url=server_url, witness_id=witness_id ) - except (ConfigurationError, OperationError): + LOGGER.info( + f"Successfully connected to witness {connected_witness_id}" + ) + except ConfigurationError as e: + LOGGER.error( + f"Configuration error while connecting to witness {witness_id}: {e}" + ) # Connection setup failed, but don't fail configuration - pass + except OperationError as e: + LOGGER.error( + f"Operation error while connecting to witness {witness_id}: {e}" + ) + # Connection setup failed, but don't fail configuration + except Exception as e: + LOGGER.exception( + f"Unexpected error while connecting to witness {witness_id}: {e}" + ) + else: + if not witness_id: + LOGGER.warning( + "Controller configured without witness_id. " + "No witness connection will be established." + ) + if not server_url: + LOGGER.warning( + "Controller configured without server_url. " + "No witness connection will be established." + ) return config async def create(self, options: dict): diff --git a/webvh/webvh/did/server_client.py b/webvh/webvh/did/server_client.py index a36bbed52..d8608f0cd 100644 --- a/webvh/webvh/did/server_client.py +++ b/webvh/webvh/did/server_client.py @@ -53,26 +53,41 @@ async def get_witness_services(self): async def get_witness_invitation(self, witness_id: str): """Get the witness invitation from the server document.""" + server_url = await get_server_url(self.profile) + LOGGER.info(f"Fetching witness services from server document at {server_url}") witness_services = await self.get_witness_services() + LOGGER.info(f"Found {len(witness_services)} witness service(s) in server document") + witness_service = next( (svc for svc in witness_services if svc.get("id") == witness_id), None ) if not witness_service: + LOGGER.error( + f"Witness {witness_id} not found in server document. " + f"Available witness IDs: {[svc.get('id') for svc in witness_services]}" + ) raise OperationError(f"Witness {witness_id} not listed by server document.") invitation_url = witness_service.get("serviceEndpoint") + LOGGER.info(f"Found witness service endpoint: {invitation_url}") parsed_key = parse_did_key(witness_id) - if ( - invitation_url - != f"{await get_server_url(self.profile)}?_oobid={parsed_key.key}" - ): + expected_url = f"{server_url}?_oobid={parsed_key.key}" + if invitation_url != expected_url: + LOGGER.error( + f"Witness service endpoint mismatch. " + f"Expected: {expected_url}, Got: {invitation_url}" + ) raise OperationError( "Witness service endpoint does not match server document." ) + LOGGER.info(f"Fetching invitation from {invitation_url}") async with ClientSession() as session: response = await session.get(invitation_url) - return await response.json() + response.raise_for_status() + invitation = await response.json() + LOGGER.info(f"Successfully fetched invitation (id: {invitation.get('@id', 'unknown')})") + return invitation async def request_identifier(self, namespace, identifier) -> tuple: """Contact the webvh server to request an identifier.""" diff --git a/webvh/webvh/protocols/events.py b/webvh/webvh/protocols/events.py index ea69265cc..e4de210d4 100644 --- a/webvh/webvh/protocols/events.py +++ b/webvh/webvh/protocols/events.py @@ -141,3 +141,4 @@ def get_event_pattern(self, record_id: str) -> str: A regex pattern string for matching the event """ return rf"^{WITNESS_EVENT_PREFIX}{record_id}$" + From 32c16b6546d58aa529289e72751f236d04105a12 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Wed, 26 Nov 2025 17:40:27 -0500 Subject: [PATCH 10/21] fix expected url server Signed-off-by: Patrick St-Louis --- webvh/webvh/did/server_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webvh/webvh/did/server_client.py b/webvh/webvh/did/server_client.py index d8608f0cd..72aab3114 100644 --- a/webvh/webvh/did/server_client.py +++ b/webvh/webvh/did/server_client.py @@ -71,7 +71,7 @@ async def get_witness_invitation(self, witness_id: str): invitation_url = witness_service.get("serviceEndpoint") LOGGER.info(f"Found witness service endpoint: {invitation_url}") parsed_key = parse_did_key(witness_id) - expected_url = f"{server_url}?_oobid={parsed_key.key}" + expected_url = f"{server_url}/api/invitations?_oobid={parsed_key.key}" if invitation_url != expected_url: LOGGER.error( f"Witness service endpoint mismatch. " From 27707499c0bbee9a64a8ba3a7a7b97bd9f040456 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Wed, 26 Nov 2025 17:46:48 -0500 Subject: [PATCH 11/21] fix expected url server Signed-off-by: Patrick St-Louis --- webvh/webvh/did/connection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/webvh/webvh/did/connection.py b/webvh/webvh/did/connection.py index 5e673a00a..b9fffede4 100644 --- a/webvh/webvh/did/connection.py +++ b/webvh/webvh/did/connection.py @@ -153,6 +153,7 @@ async def connect( LOGGER.info(f"Received invitation: {invitation.get('@id', 'unknown id')}") # Validate invitation + LOGGER.warning(invitation) if invitation.get("goal-code", None) != "witness-service": raise OperationError("Missing invitation goal-code and witness did.") From 1ceb04381546f4826f4361af8e9d41d3ccbd736c Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Wed, 26 Nov 2025 17:52:43 -0500 Subject: [PATCH 12/21] fix expected url server Signed-off-by: Patrick St-Louis --- webvh/webvh/did/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webvh/webvh/did/connection.py b/webvh/webvh/did/connection.py index b9fffede4..fb50fdc77 100644 --- a/webvh/webvh/did/connection.py +++ b/webvh/webvh/did/connection.py @@ -154,7 +154,7 @@ async def connect( # Validate invitation LOGGER.warning(invitation) - if invitation.get("goal-code", None) != "witness-service": + if invitation.get("goal_code", None) != "witness-service": raise OperationError("Missing invitation goal-code and witness did.") if invitation.get("goal", None) != witness_id: From f8e576048aa09ccaec20e5b533c4f64c156bc874 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Wed, 26 Nov 2025 18:01:40 -0500 Subject: [PATCH 13/21] fix invitation Signed-off-by: Patrick St-Louis --- webvh/webvh/did/connection.py | 8 +++++--- webvh/webvh/did/controller.py | 20 +++++--------------- webvh/webvh/protocols/events.py | 1 + 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/webvh/webvh/did/connection.py b/webvh/webvh/did/connection.py index fb50fdc77..af0b4df35 100644 --- a/webvh/webvh/did/connection.py +++ b/webvh/webvh/did/connection.py @@ -11,7 +11,10 @@ OutOfBandManager, OutOfBandManagerError, ) -from acapy_agent.protocols.out_of_band.v1_0.messages.invitation import HSProto +from acapy_agent.protocols.out_of_band.v1_0.messages.invitation import ( + HSProto, + InvitationMessage, +) from ..config.config import ( get_plugin_config, @@ -153,7 +156,6 @@ async def connect( LOGGER.info(f"Received invitation: {invitation.get('@id', 'unknown id')}") # Validate invitation - LOGGER.warning(invitation) if invitation.get("goal_code", None) != "witness-service": raise OperationError("Missing invitation goal-code and witness did.") @@ -173,7 +175,7 @@ async def connect( witness_alias = f"{server_url}@{witness_key}" LOGGER.info(f"Receiving invitation with alias: {witness_alias}") await OutOfBandManager(self.profile).receive_invitation( - invitation=invitation, + invitation=InvitationMessage.deserialize(invitation), auto_accept=True, alias=witness_alias, ) diff --git a/webvh/webvh/did/controller.py b/webvh/webvh/did/controller.py index 787e36e04..d9b9d3262 100644 --- a/webvh/webvh/did/controller.py +++ b/webvh/webvh/did/controller.py @@ -251,7 +251,7 @@ async def configure(self, config: dict) -> dict: witness_id = config.get("witness_id") server_url = config.get("server_url") if witness_id and server_url: - LOGGER.warning( + LOGGER.info( f"Configuring controller: attempting to connect to witness {witness_id} " f"at server {server_url}" ) @@ -260,32 +260,22 @@ async def configure(self, config: dict) -> dict: server_url=server_url, witness_id=witness_id ) if connection: - LOGGER.warning( + LOGGER.info( f"Already connected to witness {witness_id} " f"(connection_id: {connection.connection_id})" ) else: try: - LOGGER.warning(f"Connecting to witness {witness_id}...") + LOGGER.info(f"Connecting to witness {witness_id}...") connected_witness_id = await self.witness_connection.connect( server_url=server_url, witness_id=witness_id ) LOGGER.info( f"Successfully connected to witness {connected_witness_id}" ) - except ConfigurationError as e: - LOGGER.error( - f"Configuration error while connecting to witness {witness_id}: {e}" - ) - # Connection setup failed, but don't fail configuration - except OperationError as e: + except (ConfigurationError, OperationError, Exception) as e: LOGGER.error( - f"Operation error while connecting to witness {witness_id}: {e}" - ) - # Connection setup failed, but don't fail configuration - except Exception as e: - LOGGER.exception( - f"Unexpected error while connecting to witness {witness_id}: {e}" + f"Error while connecting to witness {witness_id}: {e}" ) else: if not witness_id: diff --git a/webvh/webvh/protocols/events.py b/webvh/webvh/protocols/events.py index e4de210d4..0706bb0df 100644 --- a/webvh/webvh/protocols/events.py +++ b/webvh/webvh/protocols/events.py @@ -142,3 +142,4 @@ def get_event_pattern(self, record_id: str) -> str: """ return rf"^{WITNESS_EVENT_PREFIX}{record_id}$" + From a5c715f5ad5ebb40a727fd19227c8246ee2a36bb Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Wed, 26 Nov 2025 18:21:20 -0500 Subject: [PATCH 14/21] fix invitation Signed-off-by: Patrick St-Louis --- webvh/webvh/did/connection.py | 38 +++++++++++++++++++++++++++++++++-- webvh/webvh/routes.py | 4 +--- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/webvh/webvh/did/connection.py b/webvh/webvh/did/connection.py index af0b4df35..dab8d9dd3 100644 --- a/webvh/webvh/did/connection.py +++ b/webvh/webvh/did/connection.py @@ -3,6 +3,7 @@ import asyncio import logging from typing import Optional +from urllib.parse import urlparse from acapy_agent.connections.models.conn_record import ConnRecord from acapy_agent.core.profile import Profile @@ -15,6 +16,11 @@ HSProto, InvitationMessage, ) +from acapy_agent.wallet.base import BaseWallet +from acapy_agent.wallet.did_info import DIDInfo +from acapy_agent.wallet.did_method import KEY +from acapy_agent.wallet.error import WalletDuplicateError, WalletNotFoundError +from acapy_agent.wallet.key_type import ED25519 from ..config.config import ( get_plugin_config, @@ -73,7 +79,9 @@ async def get_active_connection( # Build alias with witness_id parsed_key = parse_did_key(witness_id) witness_key = parsed_key.key - witness_alias = f"{server_url}@{witness_key}" + # Extract domain from server_url + domain = urlparse(server_url).netloc + witness_alias = f"webvh:{domain}@{witness_key}" LOGGER.debug(f"Looking for active connection with alias: {witness_alias}") async with self.profile.session() as session: connection_records = await ConnRecord.retrieve_by_alias( @@ -172,7 +180,9 @@ async def connect( # Extract key from witness_id for alias parsed_key = parse_did_key(witness_id) witness_key = parsed_key.key - witness_alias = f"{server_url}@{witness_key}" + # Extract domain from server_url + domain = urlparse(server_url).netloc + witness_alias = f"webvh:{domain}@{witness_key}" LOGGER.info(f"Receiving invitation with alias: {witness_alias}") await OutOfBandManager(self.profile).receive_invitation( invitation=InvitationMessage.deserialize(invitation), @@ -223,6 +233,29 @@ async def create_witness_invitation( Raises: WitnessError: If invitation creation fails """ + # Ensure the witness DID is in the wallet so we can use it as the invitation key + async with self.profile.session() as session: + wallet = session.inject(BaseWallet) + try: + await wallet.get_local_did(witness_id) + except WalletNotFoundError: + # Store the witness DID in the wallet if it's not already there + parsed_key = parse_did_key(witness_id) + witness_key = parsed_key.key + did_info = DIDInfo( + did=witness_id, + verkey=witness_key, + metadata={}, + method=KEY, + key_type=ED25519, + ) + try: + await wallet.store_did(did_info) + LOGGER.info(f"Stored witness DID {witness_id} in wallet") + except WalletDuplicateError: + # If it's already there (race condition), that's fine + LOGGER.debug(f"Witness DID {witness_id} already in wallet") + try: invi_rec = await OutOfBandManager(self.profile).create_invitation( hs_protos=[ @@ -234,6 +267,7 @@ async def create_witness_invitation( goal_code="witness-service", goal=witness_id, multi_use=multi_use, + use_did=witness_id, # Use the witness DID as the invitation key ) return invi_rec.serialize() except OutOfBandManagerError as e: diff --git a/webvh/webvh/routes.py b/webvh/webvh/routes.py index cac5c4373..fcebac519 100644 --- a/webvh/webvh/routes.py +++ b/webvh/webvh/routes.py @@ -257,8 +257,7 @@ async def on_startup_event(profile: Profile, event: Event): witness_config["witness_id"], witness_config.get("invitation_url") ) for line in message.strip().split("\n"): - LOGGER.warning(line) - ROOT_LOGGER.warning(f"[WebVH] {line}") + LOGGER.info(line) print(message, end="") else: # Configure controller (sets up witness connection if witness_id is configured) @@ -284,7 +283,6 @@ async def on_subwallet_created_event(profile: Profile, event: Event): "Configure WebVH settings via /did/webvh/configuration endpoint." ) LOGGER.info(msg2) - ROOT_LOGGER.info(f"[WebVH] {msg2}") async def register(app: web.Application): From a1d558a52b3740ac4e6580e1e5c4359c6a2ad471 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Wed, 26 Nov 2025 18:24:21 -0500 Subject: [PATCH 15/21] fix invitation Signed-off-by: Patrick St-Louis --- webvh/webvh/did/connection.py | 60 ++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/webvh/webvh/did/connection.py b/webvh/webvh/did/connection.py index dab8d9dd3..f66bf2249 100644 --- a/webvh/webvh/did/connection.py +++ b/webvh/webvh/did/connection.py @@ -12,6 +12,7 @@ OutOfBandManager, OutOfBandManagerError, ) +from acapy_agent.protocols.out_of_band.v1_0.models.oob_record import OobRecord from acapy_agent.protocols.out_of_band.v1_0.messages.invitation import ( HSProto, InvitationMessage, @@ -233,29 +234,32 @@ async def create_witness_invitation( Raises: WitnessError: If invitation creation fails """ - # Ensure the witness DID is in the wallet so we can use it as the invitation key + # Extract witness verkey to use as invitation key + parsed_key = parse_did_key(witness_id) + witness_verkey = parsed_key.key + + # Ensure the witness key is available as a signing key in the wallet async with self.profile.session() as session: wallet = session.inject(BaseWallet) try: - await wallet.get_local_did(witness_id) + # Check if the key exists as a signing key + await wallet.get_signing_key(witness_verkey) except WalletNotFoundError: - # Store the witness DID in the wallet if it's not already there - parsed_key = parse_did_key(witness_id) - witness_key = parsed_key.key - did_info = DIDInfo( - did=witness_id, - verkey=witness_key, - metadata={}, - method=KEY, - key_type=ED25519, + # The key should already be in the wallet from key_chain.bind_key(), + # but if not, we need to ensure it's there + # For now, we'll let the error propagate - the key should be there + LOGGER.warning( + f"Witness key {witness_verkey} not found in wallet. " + "Ensure the witness key is properly bound via key_chain.bind_key()" + ) + raise WitnessError( + f"Witness key for {witness_id} not found in wallet. " + "The key must be bound before creating invitations." ) - try: - await wallet.store_did(did_info) - LOGGER.info(f"Stored witness DID {witness_id} in wallet") - except WalletDuplicateError: - # If it's already there (race condition), that's fine - LOGGER.debug(f"Witness DID {witness_id} already in wallet") + # Create invitation using legacy approach (without use_did) + # The invitation will use a new key, but we'll update the connection record + # to use the witness key as the invitation key after creation try: invi_rec = await OutOfBandManager(self.profile).create_invitation( hs_protos=[ @@ -267,8 +271,28 @@ async def create_witness_invitation( goal_code="witness-service", goal=witness_id, multi_use=multi_use, - use_did=witness_id, # Use the witness DID as the invitation key + # Don't use use_did - it requires services which did:key doesn't have ) + + # Update the connection record to use the witness key as invitation key + if invi_rec.oob_id: + async with self.profile.session() as session: + # Get the OOB record to find the connection + oob_rec = await OobRecord.retrieve_by_id(session, invi_rec.oob_id) + if oob_rec.connection_id: + conn_rec = await ConnRecord.retrieve_by_id( + session, oob_rec.connection_id + ) + # Update the invitation_key to use the witness verkey + conn_rec.invitation_key = witness_verkey + await conn_rec.save( + session, reason="Updated invitation key to witness key" + ) + LOGGER.info( + f"Updated connection {conn_rec.connection_id} " + f"to use witness key as invitation key" + ) + return invi_rec.serialize() except OutOfBandManagerError as e: raise WitnessError(e) From b59312fa37193b5e484d8f0081006d2a75054b5f Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Wed, 26 Nov 2025 18:26:02 -0500 Subject: [PATCH 16/21] fix invitation Signed-off-by: Patrick St-Louis --- webvh/webvh/did/connection.py | 26 ++------------------------ 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/webvh/webvh/did/connection.py b/webvh/webvh/did/connection.py index f66bf2249..d9ebed2be 100644 --- a/webvh/webvh/did/connection.py +++ b/webvh/webvh/did/connection.py @@ -17,11 +17,6 @@ HSProto, InvitationMessage, ) -from acapy_agent.wallet.base import BaseWallet -from acapy_agent.wallet.did_info import DIDInfo -from acapy_agent.wallet.did_method import KEY -from acapy_agent.wallet.error import WalletDuplicateError, WalletNotFoundError -from acapy_agent.wallet.key_type import ED25519 from ..config.config import ( get_plugin_config, @@ -235,28 +230,11 @@ async def create_witness_invitation( WitnessError: If invitation creation fails """ # Extract witness verkey to use as invitation key + # Note: We extract the verkey from the witness_id (did:key format) + # The verkey is the multibase-encoded key part after "did:key:" parsed_key = parse_did_key(witness_id) witness_verkey = parsed_key.key - # Ensure the witness key is available as a signing key in the wallet - async with self.profile.session() as session: - wallet = session.inject(BaseWallet) - try: - # Check if the key exists as a signing key - await wallet.get_signing_key(witness_verkey) - except WalletNotFoundError: - # The key should already be in the wallet from key_chain.bind_key(), - # but if not, we need to ensure it's there - # For now, we'll let the error propagate - the key should be there - LOGGER.warning( - f"Witness key {witness_verkey} not found in wallet. " - "Ensure the witness key is properly bound via key_chain.bind_key()" - ) - raise WitnessError( - f"Witness key for {witness_id} not found in wallet. " - "The key must be bound before creating invitations." - ) - # Create invitation using legacy approach (without use_did) # The invitation will use a new key, but we'll update the connection record # to use the witness key as the invitation key after creation From 3060c5b371fccfb259b8f4a0494b4b2aa3e015b1 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Wed, 26 Nov 2025 18:30:02 -0500 Subject: [PATCH 17/21] fix invitation Signed-off-by: Patrick St-Louis --- webvh/webvh/did/connection.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/webvh/webvh/did/connection.py b/webvh/webvh/did/connection.py index d9ebed2be..dabb667f4 100644 --- a/webvh/webvh/did/connection.py +++ b/webvh/webvh/did/connection.py @@ -7,6 +7,7 @@ from acapy_agent.connections.models.conn_record import ConnRecord from acapy_agent.core.profile import Profile +from acapy_agent.did.did_key import DIDKey from acapy_agent.messaging.models.base import BaseModelError from acapy_agent.protocols.out_of_band.v1_0.manager import ( OutOfBandManager, @@ -17,6 +18,7 @@ HSProto, InvitationMessage, ) +from acapy_agent.wallet.key_type import ED25519 from ..config.config import ( get_plugin_config, @@ -230,10 +232,9 @@ async def create_witness_invitation( WitnessError: If invitation creation fails """ # Extract witness verkey to use as invitation key - # Note: We extract the verkey from the witness_id (did:key format) - # The verkey is the multibase-encoded key part after "did:key:" - parsed_key = parse_did_key(witness_id) - witness_verkey = parsed_key.key + # Convert did:key to base58-encoded verkey (invitation_key expects base58, not multibase) + did_key = DIDKey.from_did(witness_id) + witness_verkey = did_key.public_key_b58 # Create invitation using legacy approach (without use_did) # The invitation will use a new key, but we'll update the connection record From 28b6d7a00803ea22e71b5ef913a4f6e32766e08c Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Wed, 26 Nov 2025 23:38:36 -0500 Subject: [PATCH 18/21] improve logs Signed-off-by: Patrick St-Louis --- webvh/webvh/did/connection.py | 59 +++++++++++++++++++++++--- webvh/webvh/did/controller.py | 4 +- webvh/webvh/did/server_client.py | 11 +++-- webvh/webvh/did/utils.py | 73 ++++++++++++++++++++++++++------ webvh/webvh/protocols/events.py | 2 - webvh/webvh/routes.py | 32 +++++++------- 6 files changed, 136 insertions(+), 45 deletions(-) diff --git a/webvh/webvh/did/connection.py b/webvh/webvh/did/connection.py index dabb667f4..7a5d9e7ae 100644 --- a/webvh/webvh/did/connection.py +++ b/webvh/webvh/did/connection.py @@ -13,12 +13,13 @@ OutOfBandManager, OutOfBandManagerError, ) +from acapy_agent.protocols.out_of_band.v1_0.models.invitation import InvitationRecord from acapy_agent.protocols.out_of_band.v1_0.models.oob_record import OobRecord from acapy_agent.protocols.out_of_band.v1_0.messages.invitation import ( HSProto, InvitationMessage, ) -from acapy_agent.wallet.key_type import ED25519 +from acapy_agent.storage.error import StorageNotFoundError from ..config.config import ( get_plugin_config, @@ -148,7 +149,9 @@ async def connect( ) # Fetch invitation from server - LOGGER.info(f"Fetching witness invitation for {witness_id} from server {server_url}") + LOGGER.info( + f"Fetching witness invitation for {witness_id} from server {server_url}" + ) server_client = WebVHServerClient(self.profile) try: invitation = await server_client.get_witness_invitation(witness_id) @@ -187,7 +190,7 @@ async def connect( auto_accept=True, alias=witness_alias, ) - LOGGER.info(f"Invitation received successfully, waiting for connection...") + LOGGER.info("Invitation received successfully, waiting for connection...") except BaseModelError as err: LOGGER.error(f"Error receiving witness invitation: {err}") raise OperationError(f"Error receiving witness invitation: {err}") @@ -232,10 +235,52 @@ async def create_witness_invitation( WitnessError: If invitation creation fails """ # Extract witness verkey to use as invitation key - # Convert did:key to base58-encoded verkey (invitation_key expects base58, not multibase) + # Convert did:key to base58-encoded verkey + # (invitation_key expects base58, not multibase) did_key = DIDKey.from_did(witness_id) witness_verkey = did_key.public_key_b58 - + + # Check if there's already an existing invitation with this invitation_key + async with self.profile.session() as session: + # Query for connection records with this invitation_key + conn_records = await ConnRecord.query( + session, + tag_filter={"invitation_key": witness_verkey}, + ) + + # Find the OOB record associated with any of these connections + for conn_rec in conn_records: + if conn_rec.invitation_msg_id: + try: + # Find OOB record by invitation message ID + oob_records = await OobRecord.query( + session, + tag_filter={"invi_msg_id": conn_rec.invitation_msg_id}, + ) + if oob_records: + oob_rec = oob_records[0] + # Construct InvitationRecord from OOB record + invitation_msg = oob_rec.invitation + if invitation_msg: + # Get invitation URL + invitation_url = invitation_msg.to_url() + invi_rec = InvitationRecord( + invitation_id=oob_rec.oob_id, + state=InvitationRecord.STATE_AWAIT_RESPONSE, + invi_msg_id=oob_rec.invi_msg_id, + invitation=invitation_msg, + invitation_url=invitation_url, + oob_id=oob_rec.oob_id, + ) + LOGGER.info( + f"Reusing existing invitation for witness " + f"{witness_id} (oob_id: {oob_rec.oob_id})" + ) + return invi_rec.serialize() + except StorageNotFoundError: + # Continue to next connection record + continue + # Create invitation using legacy approach (without use_did) # The invitation will use a new key, but we'll update the connection record # to use the witness key as the invitation key after creation @@ -252,7 +297,7 @@ async def create_witness_invitation( multi_use=multi_use, # Don't use use_did - it requires services which did:key doesn't have ) - + # Update the connection record to use the witness key as invitation key if invi_rec.oob_id: async with self.profile.session() as session: @@ -271,7 +316,7 @@ async def create_witness_invitation( f"Updated connection {conn_rec.connection_id} " f"to use witness key as invitation key" ) - + return invi_rec.serialize() except OutOfBandManagerError as e: raise WitnessError(e) diff --git a/webvh/webvh/did/controller.py b/webvh/webvh/did/controller.py index d9b9d3262..e0403614d 100644 --- a/webvh/webvh/did/controller.py +++ b/webvh/webvh/did/controller.py @@ -274,9 +274,7 @@ async def configure(self, config: dict) -> dict: f"Successfully connected to witness {connected_witness_id}" ) except (ConfigurationError, OperationError, Exception) as e: - LOGGER.error( - f"Error while connecting to witness {witness_id}: {e}" - ) + LOGGER.error(f"Error while connecting to witness {witness_id}: {e}") else: if not witness_id: LOGGER.warning( diff --git a/webvh/webvh/did/server_client.py b/webvh/webvh/did/server_client.py index 72aab3114..18dec31ab 100644 --- a/webvh/webvh/did/server_client.py +++ b/webvh/webvh/did/server_client.py @@ -56,8 +56,10 @@ async def get_witness_invitation(self, witness_id: str): server_url = await get_server_url(self.profile) LOGGER.info(f"Fetching witness services from server document at {server_url}") witness_services = await self.get_witness_services() - LOGGER.info(f"Found {len(witness_services)} witness service(s) in server document") - + LOGGER.info( + f"Found {len(witness_services)} witness service(s) in server document" + ) + witness_service = next( (svc for svc in witness_services if svc.get("id") == witness_id), None ) @@ -86,7 +88,10 @@ async def get_witness_invitation(self, witness_id: str): response = await session.get(invitation_url) response.raise_for_status() invitation = await response.json() - LOGGER.info(f"Successfully fetched invitation (id: {invitation.get('@id', 'unknown')})") + LOGGER.info( + f"Successfully fetched invitation " + f"(id: {invitation.get('@id', 'unknown')})" + ) return invitation async def request_identifier(self, namespace, identifier) -> tuple: diff --git a/webvh/webvh/did/utils.py b/webvh/webvh/did/utils.py index 0f26efbe5..db2e37f50 100644 --- a/webvh/webvh/did/utils.py +++ b/webvh/webvh/did/utils.py @@ -330,27 +330,72 @@ def validate_did(did: str, domain: str, namespace: str, identifier: str) -> bool return validate_webvh_did(did, domain, namespace, identifier) -def format_witness_ready_message(witness_id: str, invitation_url: str = None) -> str: +def format_witness_ready_message( + witness_id: str, invitation_url: str = None, server_url: str = None +) -> str: """Format a witness ready message for display and logging. Args: witness_id: The witness DID identifier invitation_url: Optional invitation URL (defaults to "") + server_url: Optional server URL for building server invitation link Returns: Formatted message string """ - invitation_display = invitation_url if invitation_url else "" - return f""" -{"=" * 70} -✨{" " * 20}WebVH Witness Ready!{" " * 20}✨ -{"=" * 70} - - 🔑 Witness ID: - {witness_id} + from acapy_agent.config.banner import _Banner + from urllib.parse import urlparse, parse_qs - 📨 Invitation URL: - {invitation_display} - -{"=" * 70} -""" + # Transform invitation URL to didcomm:// format if it contains oob parameter + invitation_display = invitation_url if invitation_url else "" + if invitation_url and "oob=" in invitation_url: + try: + parsed_url = urlparse(invitation_url) + query_params = parse_qs(parsed_url.query) + if "oob" in query_params: + oob_value = query_params["oob"][0] + invitation_display = f"didcomm://?oob={oob_value}" + except Exception: + # If transformation fails, use original URL + invitation_display = invitation_url + + # Build server invitation URL if server_url is provided + server_invitation_display = "" + if server_url: + parsed_key = parse_did_key(witness_id) + witness_key = parsed_key.key + server_invitation_display = f"{server_url}/api/invitations?_oobid={witness_key}" + + # Build banner using Banner class directly (not as context manager) + # so we can access the lines before the final border is added + banner = _Banner(border=":", length=80) + banner.add_border() + banner.title("Witness Service") + banner.spacer() + # Add "Witness ID" section with dashes + banner.hr("-") + banner.centered("Witness ID") + banner.hr("-") + banner.spacer() + banner.print(witness_id) + banner.spacer() + # Add "Invitation" section with dashes + banner.hr("-") + banner.centered("Invitation") + banner.hr("-") + banner.spacer() + banner.print(invitation_display) + banner.spacer() + # Add "Server Invitation" section with dashes + banner.hr("-") + banner.centered("Server Invitation") + banner.hr("-") + banner.spacer() + banner.print(server_invitation_display) + banner.spacer() + banner.add_border() + + # Join all lines with newlines + banner_text = "\n".join(banner.lines) + + return banner_text diff --git a/webvh/webvh/protocols/events.py b/webvh/webvh/protocols/events.py index 0706bb0df..ea69265cc 100644 --- a/webvh/webvh/protocols/events.py +++ b/webvh/webvh/protocols/events.py @@ -141,5 +141,3 @@ def get_event_pattern(self, record_id: str) -> str: A regex pattern string for matching the event """ return rf"^{WITNESS_EVENT_PREFIX}{record_id}$" - - diff --git a/webvh/webvh/routes.py b/webvh/webvh/routes.py index fcebac519..b24bf267d 100644 --- a/webvh/webvh/routes.py +++ b/webvh/webvh/routes.py @@ -16,6 +16,7 @@ from .config.config import ( get_global_plugin_config, get_plugin_config, + get_server_url, set_config, ) from .did.controller import ControllerManager @@ -45,9 +46,6 @@ LOGGER = logging.getLogger(__name__) -# Also log to root logger to ensure visibility -ROOT_LOGGER = logging.getLogger() - @docs(tags=["did-webvh"], summary="Get webvh plugin configuration") @tenant_authentication @@ -219,23 +217,19 @@ async def update_whois(request: web.BaseRequest): def register_events(event_bus: EventBus): """Register to the acapy startup event.""" - # Use both loggers to ensure visibility msg = "Registering WebVH startup event handler" - LOGGER.warning("=" * 70) - LOGGER.warning(msg) - ROOT_LOGGER.warning(f"[WebVH] {msg}") - LOGGER.warning("=" * 70) + LOGGER.info("=" * 70) + LOGGER.info(msg) + LOGGER.info("=" * 70) event_bus.subscribe(STARTUP_EVENT_PATTERN, on_startup_event) msg2 = "WebVH startup event handler registered successfully" - LOGGER.warning(msg2) - ROOT_LOGGER.warning(f"[WebVH] {msg2}") + LOGGER.info(msg2) # Subscribe to subwallet creation events SUBWALLET_CREATED_PATTERN = re.compile("^acapy::multitenant::wallet::created::.*$") event_bus.subscribe(SUBWALLET_CREATED_PATTERN, on_subwallet_created_event) msg3 = "WebVH subwallet creation event handler registered successfully" LOGGER.info(msg3) - ROOT_LOGGER.info(f"[WebVH] {msg3}") async def on_startup_event(profile: Profile, event: Event): @@ -252,13 +246,20 @@ async def on_startup_event(profile: Profile, event: Event): if config.get("witness", False): # Configure witness and print information + LOGGER.info("Configuring witness service...") witness_config = await WitnessManager(profile).configure(config) + # Get server_url from config or profile + try: + server_url = await get_server_url(profile) + except ConfigurationError: + server_url = None message = format_witness_ready_message( - witness_config["witness_id"], witness_config.get("invitation_url") + witness_config["witness_id"], + witness_config.get("invitation_url"), + server_url=server_url, ) - for line in message.strip().split("\n"): - LOGGER.info(line) - print(message, end="") + # Log the entire banner as a single message + LOGGER.info(f"\n{message}") else: # Configure controller (sets up witness connection if witness_id is configured) await ControllerManager(profile).configure(config) @@ -275,7 +276,6 @@ async def on_subwallet_created_event(profile: Profile, event: Event): msg = f"Subwallet created - wallet_id: {wallet_id}, wallet_name: {wallet_name}" LOGGER.info(msg) - ROOT_LOGGER.info(f"[WebVH] {msg}") if wallet_id: msg2 = ( From c0a0136da428bc3ef85f9d49b5c4805e8c8eb1ec Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Wed, 26 Nov 2025 23:47:36 -0500 Subject: [PATCH 19/21] improve loggin Signed-off-by: Patrick St-Louis --- webvh/webvh/did/utils.py | 70 ---------------------------------------- webvh/webvh/routes.py | 41 +++++++++++++++++------ 2 files changed, 31 insertions(+), 80 deletions(-) diff --git a/webvh/webvh/did/utils.py b/webvh/webvh/did/utils.py index db2e37f50..b8a081dc5 100644 --- a/webvh/webvh/did/utils.py +++ b/webvh/webvh/did/utils.py @@ -329,73 +329,3 @@ def validate_did(did: str, domain: str, namespace: str, identifier: str) -> bool """ return validate_webvh_did(did, domain, namespace, identifier) - -def format_witness_ready_message( - witness_id: str, invitation_url: str = None, server_url: str = None -) -> str: - """Format a witness ready message for display and logging. - - Args: - witness_id: The witness DID identifier - invitation_url: Optional invitation URL (defaults to "") - server_url: Optional server URL for building server invitation link - - Returns: - Formatted message string - """ - from acapy_agent.config.banner import _Banner - from urllib.parse import urlparse, parse_qs - - # Transform invitation URL to didcomm:// format if it contains oob parameter - invitation_display = invitation_url if invitation_url else "" - if invitation_url and "oob=" in invitation_url: - try: - parsed_url = urlparse(invitation_url) - query_params = parse_qs(parsed_url.query) - if "oob" in query_params: - oob_value = query_params["oob"][0] - invitation_display = f"didcomm://?oob={oob_value}" - except Exception: - # If transformation fails, use original URL - invitation_display = invitation_url - - # Build server invitation URL if server_url is provided - server_invitation_display = "" - if server_url: - parsed_key = parse_did_key(witness_id) - witness_key = parsed_key.key - server_invitation_display = f"{server_url}/api/invitations?_oobid={witness_key}" - - # Build banner using Banner class directly (not as context manager) - # so we can access the lines before the final border is added - banner = _Banner(border=":", length=80) - banner.add_border() - banner.title("Witness Service") - banner.spacer() - # Add "Witness ID" section with dashes - banner.hr("-") - banner.centered("Witness ID") - banner.hr("-") - banner.spacer() - banner.print(witness_id) - banner.spacer() - # Add "Invitation" section with dashes - banner.hr("-") - banner.centered("Invitation") - banner.hr("-") - banner.spacer() - banner.print(invitation_display) - banner.spacer() - # Add "Server Invitation" section with dashes - banner.hr("-") - banner.centered("Server Invitation") - banner.hr("-") - banner.spacer() - banner.print(server_invitation_display) - banner.spacer() - banner.add_border() - - # Join all lines with newlines - banner_text = "\n".join(banner.lines) - - return banner_text diff --git a/webvh/webvh/routes.py b/webvh/webvh/routes.py index b24bf267d..ab2bd9bcf 100644 --- a/webvh/webvh/routes.py +++ b/webvh/webvh/routes.py @@ -36,7 +36,6 @@ WebvhSCIDQueryStringSchema, WebvhUpdateWhoisSchema, ) -from .did.utils import format_witness_ready_message from .did.witness import WitnessManager from .protocols.routes import ( get_pending_witness_requests, @@ -248,18 +247,40 @@ async def on_startup_event(profile: Profile, event: Event): # Configure witness and print information LOGGER.info("Configuring witness service...") witness_config = await WitnessManager(profile).configure(config) - # Get server_url from config or profile + + # Transform invitation URL to didcomm:// format if it contains oob parameter + invitation_url = witness_config.get("invitation_url") + invitation_display = invitation_url if invitation_url else "" + if invitation_url and "oob=" in invitation_url: + from urllib.parse import urlparse, parse_qs + try: + parsed_url = urlparse(invitation_url) + query_params = parse_qs(parsed_url.query) + if "oob" in query_params: + oob_value = query_params["oob"][0] + invitation_display = f"didcomm://?oob={oob_value}" + except Exception: + # If transformation fails, use original URL + invitation_display = invitation_url + + # Build server invitation URL if server_url is available + server_invitation_display = "" try: server_url = await get_server_url(profile) + if server_url: + from .did.utils import parse_did_key + parsed_key = parse_did_key(witness_config["witness_id"]) + witness_key = parsed_key.key + server_invitation_display = ( + f"{server_url}/api/invitations?_oobid={witness_key}" + ) except ConfigurationError: - server_url = None - message = format_witness_ready_message( - witness_config["witness_id"], - witness_config.get("invitation_url"), - server_url=server_url, - ) - # Log the entire banner as a single message - LOGGER.info(f"\n{message}") + pass + + # Log each value with label on same line (separated by newline) + LOGGER.info(f"Witness ID\n{witness_config['witness_id']}\n") + LOGGER.info(f"Invitation\n{invitation_display}\n") + LOGGER.info(f"Server Invitation\n{server_invitation_display}\n") else: # Configure controller (sets up witness connection if witness_id is configured) await ControllerManager(profile).configure(config) From 1bbb7daaa62ea9b768989ea3f29b2dbed3f95359 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Thu, 27 Nov 2025 00:04:48 -0500 Subject: [PATCH 20/21] update invitation on endpoint change Signed-off-by: Patrick St-Louis --- webvh/webvh/did/connection.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/webvh/webvh/did/connection.py b/webvh/webvh/did/connection.py index 7a5d9e7ae..a3b74f33c 100644 --- a/webvh/webvh/did/connection.py +++ b/webvh/webvh/did/connection.py @@ -240,6 +240,9 @@ async def create_witness_invitation( did_key = DIDKey.from_did(witness_id) witness_verkey = did_key.public_key_b58 + # Get current ACA-Py endpoint to compare with existing invitation endpoint + current_endpoint = self.profile.settings.get("default_endpoint") + # Check if there's already an existing invitation with this invitation_key async with self.profile.session() as session: # Query for connection records with this invitation_key @@ -262,6 +265,24 @@ async def create_witness_invitation( # Construct InvitationRecord from OOB record invitation_msg = oob_rec.invitation if invitation_msg: + # Check if endpoint has changed + if current_endpoint and invitation_msg.services: + from acapy_agent.protocols.out_of_band.v1_0.messages.service import Service + # Find first Service object with service_endpoint + existing_endpoint = None + for service_item in invitation_msg.services: + if isinstance(service_item, Service) and service_item.service_endpoint: + existing_endpoint = service_item.service_endpoint + break + + # If endpoint has changed, create new invitation + if existing_endpoint and existing_endpoint != current_endpoint: + LOGGER.info( + f"Endpoint changed from {existing_endpoint} to " + f"{current_endpoint}. Creating new invitation." + ) + break + # Get invitation URL invitation_url = invitation_msg.to_url() invi_rec = InvitationRecord( From a9e9f4b7befdf5bb1cb58fe879086cca44961774 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Thu, 4 Dec 2025 11:01:01 -0500 Subject: [PATCH 21/21] update connection event Signed-off-by: Patrick St-Louis --- webvh/webvh/did/connection.py | 133 ++++++++++++-------------------- webvh/webvh/protocols/events.py | 1 + 2 files changed, 51 insertions(+), 83 deletions(-) diff --git a/webvh/webvh/did/connection.py b/webvh/webvh/did/connection.py index a3b74f33c..0a0c2f258 100644 --- a/webvh/webvh/did/connection.py +++ b/webvh/webvh/did/connection.py @@ -234,77 +234,63 @@ async def create_witness_invitation( Raises: WitnessError: If invitation creation fails """ - # Extract witness verkey to use as invitation key - # Convert did:key to base58-encoded verkey - # (invitation_key expects base58, not multibase) - did_key = DIDKey.from_did(witness_id) - witness_verkey = did_key.public_key_b58 - # Get current ACA-Py endpoint to compare with existing invitation endpoint current_endpoint = self.profile.settings.get("default_endpoint") - # Check if there's already an existing invitation with this invitation_key + # Check if there's already an existing invitation for this witness + # Goal is stored in the invitation message, not as a tag, so we need to + # query all OOB records and filter by checking invitation.goal and serviceEndpoint async with self.profile.session() as session: - # Query for connection records with this invitation_key - conn_records = await ConnRecord.query( + # Query all OOB records (we'll filter by goal and endpoint in the invitation message) + oob_records = await OobRecord.query( session, - tag_filter={"invitation_key": witness_verkey}, + tag_filter={"role": OobRecord.ROLE_SENDER}, ) - # Find the OOB record associated with any of these connections - for conn_rec in conn_records: - if conn_rec.invitation_msg_id: - try: - # Find OOB record by invitation message ID - oob_records = await OobRecord.query( - session, - tag_filter={"invi_msg_id": conn_rec.invitation_msg_id}, - ) - if oob_records: - oob_rec = oob_records[0] - # Construct InvitationRecord from OOB record - invitation_msg = oob_rec.invitation - if invitation_msg: - # Check if endpoint has changed - if current_endpoint and invitation_msg.services: - from acapy_agent.protocols.out_of_band.v1_0.messages.service import Service - # Find first Service object with service_endpoint - existing_endpoint = None - for service_item in invitation_msg.services: - if isinstance(service_item, Service) and service_item.service_endpoint: - existing_endpoint = service_item.service_endpoint - break - - # If endpoint has changed, create new invitation - if existing_endpoint and existing_endpoint != current_endpoint: - LOGGER.info( - f"Endpoint changed from {existing_endpoint} to " - f"{current_endpoint}. Creating new invitation." - ) - break - - # Get invitation URL - invitation_url = invitation_msg.to_url() - invi_rec = InvitationRecord( - invitation_id=oob_rec.oob_id, - state=InvitationRecord.STATE_AWAIT_RESPONSE, - invi_msg_id=oob_rec.invi_msg_id, - invitation=invitation_msg, - invitation_url=invitation_url, - oob_id=oob_rec.oob_id, - ) - LOGGER.info( - f"Reusing existing invitation for witness " - f"{witness_id} (oob_id: {oob_rec.oob_id})" - ) - return invi_rec.serialize() - except StorageNotFoundError: - # Continue to next connection record - continue - - # Create invitation using legacy approach (without use_did) - # The invitation will use a new key, but we'll update the connection record - # to use the witness key as the invitation key after creation + # Find an OOB record with matching goal and endpoint + from acapy_agent.protocols.out_of_band.v1_0.messages.service import Service + for oob_rec in oob_records: + invitation_msg = oob_rec.invitation + if not invitation_msg: + continue + + # Check if goal matches + if invitation_msg.goal != witness_id: + continue + + # Check if serviceEndpoint matches current endpoint + if not current_endpoint: + continue + + # Find first Service object with service_endpoint + existing_endpoint = None + if invitation_msg.services: + for service_item in invitation_msg.services: + if isinstance(service_item, Service) and service_item.service_endpoint: + existing_endpoint = service_item.service_endpoint + break + + # Must have matching endpoint to reuse + if existing_endpoint != current_endpoint: + continue + + # Found matching invitation with correct goal and endpoint + invitation_url = invitation_msg.to_url() + invi_rec = InvitationRecord( + invitation_id=oob_rec.oob_id, + state=InvitationRecord.STATE_AWAIT_RESPONSE, + invi_msg_id=oob_rec.invi_msg_id, + invitation=invitation_msg, + invitation_url=invitation_url, + oob_id=oob_rec.oob_id, + ) + LOGGER.info( + f"Reusing existing invitation for witness " + f"{witness_id} (oob_id: {oob_rec.oob_id})" + ) + return invi_rec.serialize() + + # Create new invitation if none found or endpoint changed try: invi_rec = await OutOfBandManager(self.profile).create_invitation( hs_protos=[ @@ -319,25 +305,6 @@ async def create_witness_invitation( # Don't use use_did - it requires services which did:key doesn't have ) - # Update the connection record to use the witness key as invitation key - if invi_rec.oob_id: - async with self.profile.session() as session: - # Get the OOB record to find the connection - oob_rec = await OobRecord.retrieve_by_id(session, invi_rec.oob_id) - if oob_rec.connection_id: - conn_rec = await ConnRecord.retrieve_by_id( - session, oob_rec.connection_id - ) - # Update the invitation_key to use the witness verkey - conn_rec.invitation_key = witness_verkey - await conn_rec.save( - session, reason="Updated invitation key to witness key" - ) - LOGGER.info( - f"Updated connection {conn_rec.connection_id} " - f"to use witness key as invitation key" - ) - return invi_rec.serialize() except OutOfBandManagerError as e: raise WitnessError(e) diff --git a/webvh/webvh/protocols/events.py b/webvh/webvh/protocols/events.py index ea69265cc..e4de210d4 100644 --- a/webvh/webvh/protocols/events.py +++ b/webvh/webvh/protocols/events.py @@ -141,3 +141,4 @@ def get_event_pattern(self, record_id: str) -> str: A regex pattern string for matching the event """ return rf"^{WITNESS_EVENT_PREFIX}{record_id}$" +