diff --git a/webvh/README.md b/webvh/README.md index eac151ee5..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. @@ -109,12 +195,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 +209,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,10 +309,98 @@ 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. +#### 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` @@ -242,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/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..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 @@ -22,6 +20,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 +41,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): @@ -127,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/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/connection.py b/webvh/webvh/did/connection.py new file mode 100644 index 000000000..0a0c2f258 --- /dev/null +++ b/webvh/webvh/did/connection.py @@ -0,0 +1,310 @@ +"""WebVH Connection Manager for handling WebVH connection lifecycle.""" + +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 +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, + 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.storage.error import StorageNotFoundError + +from ..config.config import ( + get_plugin_config, + get_server_url, +) +from .exceptions import ConfigurationError, OperationError, WitnessError +from .server_client import WebVHServerClient +from .utils import parse_did_key + +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 + + async def get_active_connection( + self, + server_url: str = None, + witness_id: str = None, + ) -> Optional[ConnRecord]: + """Get an active witness connection if one exists. + + Args: + 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 + """ + # 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") + + # Build alias with witness_id + parsed_key = parse_did_key(witness_id) + witness_key = parsed_key.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( + session, witness_alias + ) + + active_connections = [ + conn for conn in connection_records if conn.state == "active" + ] + + 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( + self, + server_url: str = None, + 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 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 + 5. Receives the invitation and establishes connection + 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 + 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 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) + witness_id = config.get("witness_id") + if not witness_id: + raise ConfigurationError( + "No witness_id configured. Cannot connect to witness." + ) + + # Fetch invitation from server + 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) + 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.") + + 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(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: + # Extract key from witness_id for alias + parsed_key = parse_did_key(witness_id) + witness_key = parsed_key.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), + auto_accept=True, + alias=witness_alias, + ) + 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}") + + # Wait for connection to become active (if requested) + if wait_for_connection: + for attempt in range(max_retries): + 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) + + 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_witness_invitation( + self, + witness_id: str, + alias: str = None, + label: str = None, + multi_use: bool = False, + ) -> dict: + """Create a witness invitation for controllers to connect. + + Args: + 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 + + Returns: + Dictionary containing the invitation (with invitation_url key) + + Raises: + WitnessError: If invitation creation fails + """ + # 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 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 all OOB records (we'll filter by goal and endpoint in the invitation message) + oob_records = await OobRecord.query( + session, + tag_filter={"role": OobRecord.ROLE_SENDER}, + ) + + # 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=[ + 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=witness_id, + multi_use=multi_use, + # Don't use use_did - it requires services which did:key doesn't have + ) + + 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 new file mode 100644 index 000000000..e0403614d --- /dev/null +++ b/webvh/webvh/did/controller.py @@ -0,0 +1,538 @@ +"""DID Webvh Manager.""" + +import asyncio +import json +import logging +import re +from uuid import uuid4 +from typing import Callable, Awaitable + +from acapy_agent.core.event_bus import EventBus +from acapy_agent.core.profile import Profile +from acapy_agent.wallet.askar import CATEGORY_DID +from acapy_agent.wallet.keys.manager import ( + multikey_to_verkey, + verkey_to_multikey, +) +from did_webvh.core.state import DocumentState + +from ..config.config import ( + add_scid_mapping, + did_from_scid, + get_plugin_config, + get_server_domain, + is_witness, + notify_watchers, +) +from ..protocols.attested_resource.record import PendingAttestedResourceRecord +from ..protocols.log_entry.record import PendingLogEntryRecord +from ..protocols.states import WitnessingState, WitnessingStateHandler +from ..protocols.events import WitnessEventManager +from .connection import WebVHConnectionManager +from .exceptions import ( + ConfigurationError, + DidCreationError, + OperationError, +) +from .key_chain import KeyChainManager +from .parameters import ParameterResolver +from .server_client import WebVHServerClient, WebVHWatcherClient +from .utils import ( + add_proof, + multikey_to_jwk, + parse_webvh, + validate_did, + verify_proof, +) +from .witness import WitnessManager + +LOGGER = logging.getLogger(__name__) + +WITNESS_WAIT_TIMEOUT_SECONDS = 2 +PENDING_MESSAGE = { + "status": WitnessingState.PENDING.value, + "message": "The witness is pending.", +} + + +class ControllerManager: + """DID Webvh Manager class.""" + + 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 _sign_log_entry(self, log_entry): + did = log_entry.get("state", {}).get("id", None) + 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 _request_witness_signature(self, request_id): + if await is_witness(self.profile): + return PENDING_MESSAGE + + try: + await self.pending_log_entries.set_pending_record_id(self.profile, request_id) + return await asyncio.wait_for( + self._wait_for_log_entry(request_id), + WITNESS_WAIT_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + return { + "status": "unknown", + "message": "No immediate response from witness agent.", + } + + async def _save_local_did(self, did): + 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, + did, + value_json={ + "did": did, + "verkey": multikey_to_verkey(signing_key) if signing_key else None, + "metadata": { + "posted": True, + "scid": parsed.scid, + "domain": parsed.domain, + "namespace": parsed.namespace, + "identifier": parsed.identifier, + }, + "method": "webvh", + "key_type": "ed25519", + }, + tags={}, + ) + + def _create_didcomm_service(self, did_doc): + did = did_doc.get("id") + return { + "id": f"{did}#did-communication", + "type": "did-communication", + "serviceEndpoint": self.profile.settings.get("default_endpoint"), + "recipientKeys": [did_doc.get("authentication", None)[0]], + } + + async def _create_preliminary_doc(self, placeholder_id): + # Create a signing key + signing_key = await self.key_chain.create_key() + public_signing_key_id = f"{placeholder_id}#{signing_key}" + + # 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 self.key_chain.bind_key(signing_key, f"{did_web}#{signing_key}") + + return { + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://www.w3.org/ns/cid/v1", + ], + "id": placeholder_id, + "authentication": [public_signing_key_id], + "assertionMethod": [public_signing_key_id], + "verificationMethod": [ + { + "type": "Multikey", + "id": public_signing_key_id, + "controller": placeholder_id, + "publicKeyMultibase": signing_key, + } + ], + "service": [], + } + + async def _create_initial_log_entry( + self, preliminary_doc, parameters_input, timestamp: str = None + ): + # We update the key id's stored during the preliminary log entry processing + placeholder_id = preliminary_doc.get("id") + doc_state = DocumentState.initial( + parameters_input, preliminary_doc, timestamp=timestamp + ) + initial_log_entry = doc_state.history_line() + document = initial_log_entry.get("state") + did = document.get("id") + + # 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"): + await self.key_chain.migrate_key(placeholder_id, did, "nextKey") + + return initial_log_entry + + 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. + + 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(self.event_manager.get_event_pattern(record_id)) + ) as await_event: + event = await await_event + if ( + event.payload.get("metadata", {}).get("state") + == WitnessingState.PENDING.value + ): + return PENDING_MESSAGE + else: + await pending_record_manager.remove_pending_record_id( + self.profile, record_id + ) + return await handler(event.payload, record_id) + + 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, + ) + + return await self._wait_for_witness_event( + record_id, self.pending_log_entries, handler + ) + + async def _wait_for_resource(self, record_id: str): + """Wait for resource witness event.""" + + 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 await self._wait_for_witness_event( + record_id, self.pending_attested_resource, handler + ) + + async def configure(self, config: dict) -> dict: + """Configure did controller. + + This sets up the witness connection if witness_id is configured. + """ + # 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: + LOGGER.info( + 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 connection: + LOGGER.info( + f"Already connected to witness {witness_id} " + f"(connection_id: {connection.connection_id})" + ) + else: + try: + 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, OperationError, Exception) as e: + LOGGER.error(f"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): + """Create DID and first log entry.""" + + # Set default namespace and random identifier if none provided + domain = await get_server_domain(self.profile) + namespace = options.get("namespace", "default") + identifier = options.get("identifier", str(uuid4())) + + # Contact the server to request the identifier + requested_identifier = await self.server_client.request_identifier( + namespace, identifier + ) + + # Validate if the returned identifier matches the provided options + placeholder_id = requested_identifier.get("state", {}).get("id", None) + 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) + ( + 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), + ) + + # Add a verification method to the initial state document & create preliminary doc + preliminary_doc = await self._create_preliminary_doc(placeholder_id) + + if resolved_options.get("didcomm", False): + preliminary_doc["service"].append( + self._create_didcomm_service(preliminary_doc) + ) + + # Create and sign initial log entry + initial_log_entry = await self._create_initial_log_entry( + preliminary_doc, parameters_input, resolved_options.get("version_time", None) + ) + return await self._sign_log_entry(initial_log_entry) + + async def update(self, scid: str, did_document: dict = None, options: dict = None): + """Update a Webvh DID.""" + did = await did_from_scid(self.profile, scid) + document_state = await self.server_client.fetch_document_state(did) + + parameters = document_state.params + params_update = {} + + # Process prerotation + if parameters.get("nextKeyHashes"): + update_key, next_key_hash = await self.key_chain.rotate_update_key(did) + params_update["updateKeys"] = [update_key] + params_update["nextKeyHashes"] = [next_key_hash] + + # Create and sign log entry + new_log_entry = document_state.create_next( + document=did_document or None, params_update=params_update + ) + return await self._sign_log_entry(new_log_entry.history_line()) + + async def deactivate(self, scid: str, options: dict = None): + """Create a Webvh DID.""" + did = await did_from_scid(self.profile, scid) + document_state = await self.server_client.fetch_document_state(did) + + parameters = document_state.params + params_update = {"deactivated": True} + + # Process prerotation + if parameters.get("nextKeyHashes"): + 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( + { + "@context": ["https://www.w3.org/ns/did/v1"], + "id": document_state.document_id, + }, + params_update, + ) + return await self._sign_log_entry(log_entry.history_line()) + + async def streamline_did_operation(self, log_entry): + """Streamline all DID operations.""" + + # Process witnessing + did = log_entry.get("state", {}).get("id", None) + parsed = parse_webvh(did) + document_state = DocumentState.load_history_line( + log_entry, await self.server_client.fetch_document_state(did) + ) + witness_signature = None + if document_state.witness_rule: + witness_request_id = str(uuid4()) + witness_signature = await self.witness.witness_log_entry( + parsed.scid, log_entry, witness_request_id + ) + + if not isinstance(witness_signature, dict): + return await self._request_witness_signature(witness_request_id) + + return await self.finish_did_operation( + log_entry, + witness_signature, + state=WitnessingState.SUCCESS.value, + ) + + async def finish_did_operation( + self, + log_entry: dict, + witness_signature: dict = None, + state: str = WitnessingState.SUCCESS.value, + record_id: str = None, + ): + """Finish all DID operations.""" + + 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, + ) + + async def add_verification_method( + self, + scid: str, + key_type: str, + relationships: list, + key_id: str = None, + multikey: str = None, + ): + """Add a verification method.""" + async with self.profile.session() as session: + scid_info = await session.handle.fetch("scid", scid) + + did_document = scid_info.value_json.get("didDocument") + did = did_document.get("id") + multikey = ( + await self.key_chain.find_multikey(multikey) + if multikey + else await self.key_chain.create_key() + ) + if key_type == "Multikey": + verification_method = { + "type": key_type, + "id": f"{did}#{key_id}" if key_id else f"{did}#{multikey}", + "controller": did, + "publicKeyMultibase": multikey, + } + elif key_type == "JsonWebKey": + jwk, thumbprint = multikey_to_jwk(multikey) + verification_method = { + "type": key_type, + "id": f"{did}#{key_id}" if key_id else f"{did}#{thumbprint}", + "controller": did, + "publicKeyJwk": jwk, + } + + 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"]) + + return did_document + + async def remove_verification_method(self, scid: str, key_id: str): + """Remove a verification method.""" + did = await did_from_scid(self.profile, scid) + await self.key_chain.unbind_verification_method(did, key_id) + return {"status": "ok"} + + async def update_whois(self, scid: str, presentation: dict, options: dict = {}): + """Update WHOIS linked VP.""" + + holder_id = await did_from_scid(self.profile, scid) + + # Overwrite holder_id with controller did + presentation["holder"] = holder_id + + for credential in presentation.get("verifiableCredential"): + if credential.get("credentialSubject").get("id") != holder_id: + # NOTE, should we enforce this? + pass + # raise OperationError("Credential subject id doesn't match holder.") + + if not (await verify_proof(self.profile, credential)).verified: + # NOTE, should we enforce this? + pass + # LOGGER.info("Credential verification failed.") + # LOGGER.info(json.dumps(credential)) + # raise OperationError("Credential verification failed.") + + async with self.profile.session() as session: + did_info = await session.handle.fetch(CATEGORY_DID, holder_id) + + signing_key = verkey_to_multikey( + json.loads(did_info.value).get("verkey"), "ed25519" + ) + + vp = await add_proof( + self.profile, presentation, f"{holder_id}#{signing_key}", "authentication" + ) + return await self.server_client.submit_whois(vp) + + async def upload_resource(self, attested_resource, state, record_id): + """Upload an attested resource to the server.""" + + 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/manager.py b/webvh/webvh/did/manager.py deleted file mode 100644 index 4139ba942..000000000 --- a/webvh/webvh/did/manager.py +++ /dev/null @@ -1,835 +0,0 @@ -"""DID Webvh Manager.""" - -import asyncio -import json -import logging -import re -from uuid import uuid4 -from typing import Optional -from operator import itemgetter -import uuid - -from acapy_agent.connections.models.conn_record import ConnRecord -from acapy_agent.core.event_bus import Event, 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, - verkey_to_multikey, -) -from did_webvh.core.state import DocumentState - -from ..config.config import ( - 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 .witness import WitnessManager -from .exceptions import DidCreationError, OperationError -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, -) - -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.", -} - - -class ControllerManager: - """DID Webvh Manager class.""" - - def __init__(self, profile: Profile) -> None: - """Initialize the DID Webvh Manager.""" - self.profile = profile - self.witness = WitnessManager(self.profile) - 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]: - server_url = await get_server_url(self.profile) - 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") - 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 - if options.get("witnessThreshold", 0): - parameters["witness"] = { - "threshold": options.get("witnessThreshold"), - "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 - - try: - await self.pending_log_entries.set_pending_record_id(self.profile, request_id) - return await asyncio.wait_for( - self._wait_for_log_entry(request_id), - WITNESS_WAIT_TIMEOUT_SECONDS, - ) - except asyncio.TimeoutError: - return { - "status": "unknown", - "message": "No immediate response from witness agent.", - } - - 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") - async with self.profile.session() as session: - await session.handle.insert( - CATEGORY_DID, - did, - value_json={ - "did": did, - "verkey": multikey_to_verkey(signing_key) if signing_key else None, - "metadata": { - "posted": True, - "scid": scid, - "domain": domain, - "namespace": namespace, - "identifier": identifier, - }, - "method": "webvh", - "key_type": "ed25519", - }, - tags={}, - ) - - def _create_didcomm_service(self, did_doc): - did = did_doc.get("id") - return { - "id": f"{did}#did-communication", - "type": "did-communication", - "serviceEndpoint": self.profile.settings.get("default_endpoint"), - "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) - 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 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}") - - return { - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://www.w3.org/ns/cid/v1", - ], - "id": placeholder_id, - "authentication": [public_signing_key_id], - "assertionMethod": [public_signing_key_id], - "verificationMethod": [ - { - "type": "Multikey", - "id": public_signing_key_id, - "controller": placeholder_id, - "publicKeyMultibase": signing_key, - } - ], - "service": [], - } - - async def _create_initial_log_entry( - self, preliminary_doc, parameters_input, timestamp: str = None - ): - # 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 - ) - initial_log_entry = doc_state.history_line() - 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 - 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") - - 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_resource(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_attested_resource.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. - - options: The user provided did creation options. - defaults: The default configured options. - - """ - options["portability"] = options.get( - "portability", defaults.get("portability", False) - ) - options["prerotation"] = options.get( - "prerotation", defaults.get("prerotation", False) - ) - 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") - - if parameters.get("nextKeyHashes", None) == []: - options["prerotation"] = True - - return options - - async def configure(self, options: 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) - - 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) - - return config - - async def connect_to_witness(self, witness_invitation) -> None: - """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" - ): - raise OperationError("Missing invitation goal-code and witness did.") - - # 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") - - try: - server_domain = await get_server_domain(self.profile) - alias = f"webvh:{server_domain}@witness" - await OutOfBandManager(self.profile).receive_invitation( - invitation=InvitationMessage.from_url(witness_invitation), - auto_accept=True, - alias=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 decoded_invitation.get("goal") - 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 " - "restart the agent when witness is available." - ) - return decoded_invitation.get("goal") - - async def create(self, options: dict): - """Create DID and first log entry.""" - - # Set default namespace and random identifier if none provided - domain = await get_server_domain(self.profile) - namespace = options.get("namespace", "default") - identifier = options.get("identifier", str(uuid4())) - - # Contact the server to request the identifier - requested_identifier = await self.server_client.request_identifier( - namespace, identifier - ) - - # Validate if the returned identifier matches the provided options - placeholder_id = requested_identifier.get("state", {}).get("id", None) - if not validate_did(placeholder_id, domain, namespace, identifier): - raise DidCreationError(f"Server returned invalid did: {placeholder_id}") - - config = await get_plugin_config(self.profile) - options = await self._apply_config_defaults( - options, config.get("parameter_options", {}) - ) - - # 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): - 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) - ) - return await self._sign_log_entry(initial_log_entry) - - async def update(self, scid: str, did_document: dict = None, options: dict = None): - """Update a Webvh DID.""" - did = await did_from_scid(self.profile, scid) - document_state = await self.server_client.fetch_document_state(did) - - parameters = document_state.params - params_update = {} - - # Process prerotation - if parameters.get("nextKeyHashes"): - update_key, next_key_hash = await self._rotate_update_key(did) - params_update["updateKeys"] = [update_key] - params_update["nextKeyHashes"] = [next_key_hash] - - # Create and sign log entry - new_log_entry = document_state.create_next( - document=did_document or None, params_update=params_update - ) - return await self._sign_log_entry(new_log_entry.history_line()) - - async def deactivate(self, scid: str, options: dict = None): - """Create a Webvh DID.""" - did = await did_from_scid(self.profile, scid) - document_state = await self.server_client.fetch_document_state(did) - - parameters = document_state.params - params_update = {"deactivated": True} - - # Process prerotation - if parameters.get("nextKeyHashes"): - update_key, next_key_hash = await self._rotate_update_key(did) - params_update["nextKeyHashes"] = [next_key_hash] - - log_entry = document_state.create_next( - { - "@context": ["https://www.w3.org/ns/did/v1"], - "id": document_state.document_id, - }, - params_update, - ) - return await self._sign_log_entry(log_entry.history_line()) - - async def streamline_did_operation(self, log_entry): - """Streamline all DID operations.""" - - # Process witnessing - did = log_entry.get("state", {}).get("id", None) - scid = itemgetter(2)(did.split(":")) - document_state = DocumentState.load_history_line( - log_entry, await self.server_client.fetch_document_state(did) - ) - witness_signature = None - 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 - ) - - if not isinstance(witness_signature, dict): - return await self._request_witness_signature(witness_request_id) - - return await self.finish_did_operation( - log_entry, - witness_signature, - state=WitnessingState.SUCCESS.value, - ) - - async def finish_did_operation( - self, - log_entry: dict, - witness_signature: dict = None, - state: str = WitnessingState.SUCCESS.value, - record_id: str = None, - ): - """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 - ) - - # 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, - key_type: str, - relationships: list, - key_id: str = None, - multikey: str = None, - ): - """Add a verification method.""" - async with self.profile.session() as session: - scid_info = await session.handle.fetch("scid", scid) - - did_document = scid_info.value_json.get("didDocument") - did = did_document.get("id") - multikey = ( - await find_multikey(self.profile, multikey) - if multikey - else await create_key(self.profile) - ) - if key_type == "Multikey": - verification_method = { - "type": key_type, - "id": f"{did}#{key_id}" if key_id else f"{did}#{multikey}", - "controller": did, - "publicKeyMultibase": multikey, - } - elif key_type == "JsonWebKey": - jwk, thumbprint = multikey_to_jwk(multikey) - verification_method = { - "type": key_type, - "id": f"{did}#{key_id}" if key_id else f"{did}#{thumbprint}", - "controller": did, - "publicKeyJwk": jwk, - } - - await bind_key(multikey, verification_method["id"]) - did_document["verificationMethod"].append(verification_method) - for relationship in relationships: - did_document[relationship].append(verification_method["id"]) - - return did_document - - 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) - 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.""" - - holder_id = await did_from_scid(self.profile, scid) - - # Overwrite holder_id with controller did - presentation["holder"] = holder_id - - for credential in presentation.get("verifiableCredential"): - if credential.get("credentialSubject").get("id") != holder_id: - # NOTE, should we enforce this? - pass - # raise OperationError("Credential subject id doesn't match holder.") - - if not (await verify_proof(self.profile, credential)).verified: - # NOTE, should we enforce this? - pass - # LOGGER.info("Credential verification failed.") - # LOGGER.info(json.dumps(credential)) - # raise OperationError("Credential verification failed.") - - async with self.profile.session() as session: - did_info = await session.handle.fetch(CATEGORY_DID, holder_id) - - signing_key = verkey_to_multikey( - json.loads(did_info.value).get("verkey"), "ed25519" - ) - - vp = await add_proof( - self.profile, presentation, f"{holder_id}#{signing_key}", "authentication" - ) - return await self.server_client.submit_whois(vp) - - 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_witness_setup(self) -> None: - """Automatically set up the witness the connection.""" - domain = await get_server_domain(self.profile) - witness_alias = create_alias(domain, "witnessConnection") - - 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 - - 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}") - - for _ in range(5): - if await self._get_active_witness_connection(): - LOGGER.info("Connected to witness agent.") - return - 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." - ) 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/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 87ff1604c..18dec31ab 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 @@ -39,6 +38,62 @@ 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 await 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.""" + 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) + expected_url = f"{server_url}/api/invitations?_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) + response.raise_for_status() + invitation = await response.json() + LOGGER.info( + f"Successfully fetched invitation " + f"(id: {invitation.get('@id', 'unknown')})" + ) + return invitation + async def request_identifier(self, namespace, identifier) -> tuple: """Contact the webvh server to request an identifier.""" async with ClientSession() as session: @@ -79,10 +134,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)), ) @@ -102,10 +158,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() @@ -131,12 +188,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}, ) @@ -149,11 +207,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 d5b1f642c..5b45727e1 100644 --- a/webvh/webvh/did/tests/test_controller_manager.py +++ b/webvh/webvh/did/tests/test_controller_manager.py @@ -1,17 +1,18 @@ 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 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}"}} ) @@ -151,15 +155,94 @@ 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 + + # 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(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.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_server_client_class, mock_oob_manager + ): + desired_witness_id = f"did:key:{TEST_WITNESS_KEY}" + 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.witness_connection, + "get_active_connection", + connection_checker, + ): + result = await self.controller.witness_connection.connect( + witness_id=desired_witness_id + ) + + mock_server_client.get_witness_invitation.assert_awaited_once_with( + desired_witness_id + ) + mock_receive.assert_awaited_once() + assert result == desired_witness_id + @mock.patch("asyncio.sleep", mock.AsyncMock()) @mock.patch( "aiohttp.ClientSession.post", @@ -186,8 +269,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 148237f81..cdb814337 100644 --- a/webvh/webvh/did/tests/test_witness_manager.py +++ b/webvh/webvh/did/tests/test_witness_manager.py @@ -1,18 +1,16 @@ -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 ..manager import ControllerManager +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 PENDING_DOCUMENT_TABLE_NAME = PendingLogEntryRecord().RECORD_TYPE @@ -50,31 +48,11 @@ 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() + # Tests for setup() method removed - connection setup now happens in controller.configure() - @mock.patch.object(WitnessManager, "_get_active_witness_connection") - async def test_auto_witness_setup_as_witness( - self, mock_get_active_witness_connection - ): - self.profile.settings.set_value( - "plugin_config", - {"webvh": {"witness": True, "server_url": SERVER_URL}}, - ) - await self.controller.auto_witness_setup() - assert not mock_get_active_witness_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.auto_witness_setup() - - async def test_auto_witness_setup_as_controller_with_previous_connection(self): + async def test_witness_auto_setup_skips_when_not_configured(self): self.profile.settings.set_value( "plugin_config", { @@ -84,70 +62,58 @@ async def test_auto_witness_setup_as_controller_with_previous_connection(self): } }, ) - async with self.profile.session() as session: - record = ConnRecord( - alias=f"{SERVER_URL}@Witness", - state="active", - ) - await record.save(session) - await self.controller.auto_witness_setup() + await self.witness.configure() + config = await get_plugin_config(self.profile) + assert "witnesses" not in config - async def test_auto_witness_setup_as_controller_no_witness_invitation(self): - self.profile.settings.set_value( - "plugin_config", + async def test_witness_auto_setup_creates_key_and_updates_config(self): + profile = await create_test_profile( { - "webvh": { - "witness": False, - "server_url": SERVER_URL, - } - }, - ) - await self.controller.auto_witness_setup() - - @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) + "wallet.type": "askar-anoncreds", + "default_label": "TestWitness", + "default_endpoint": "https://example.com", + } ) - await self.controller.auto_witness_setup() - - @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( + profile.settings.set_value( "plugin_config", { "webvh": { - "witness": False, + "witness": True, "server_url": SERVER_URL, - "witness_invitation": "http://witness:9050?oob=eyJAdHlwZSI6ICJodHRwczovL2RpZGNvbW0ub3JnL291dC1vZi1iYW5kLzEuMS9pbnZpdGF0aW9uIiwgIkBpZCI6ICIwZDkwMGVjMC0wYzE3LTRmMTYtOTg1ZC1mYzU5MzVlYThjYTkiLCAibGFiZWwiOiAidGR3LWVuZG9yc2VyIiwgImhhbmRzaGFrZV9wcm90b2NvbHMiOiBbImh0dHBzOi8vZGlkY29tbS5vcmcvZGlkZXhjaGFuZ2UvMS4wIl0sICJzZXJ2aWNlcyI6IFt7ImlkIjogIiNpbmxpbmUiLCAidHlwZSI6ICJkaWQtY29tbXVuaWNhdGlvbiIsICJyZWNpcGllbnRLZXlzIjogWyJkaWQ6a2V5Ono2TWt0bXJUQURBWWRlc2Ftb3F1ZVV4NHNWM0g1Mms5b2ZoQXZRZVFaUG9vdTE3ZSN6Nk1rdG1yVEFEQVlkZXNhbW9xdWVVeDRzVjNINTJrOW9maEF2UWVRWlBvb3UxN2UiXSwgInNlcnZpY2VFbmRwb2ludCI6ICJodHRwOi8vbG9jYWxob3N0OjkwNTAifV19", } }, ) - self.profile.context.injector.bind_instance( + profile.context.injector.bind_instance(KeyTypes, KeyTypes()) + 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.auto_witness_setup() + witness = WitnessManager(profile) + with mock.patch.object( + witness.witness_connection, + "create_witness_invitation", + new=mock.AsyncMock(return_value={"invitation_url": "https://example.com"}), + ): + await witness.configure() + + 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.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 1f178b33a..b8a081dc5 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 @@ -17,9 +18,44 @@ "nextKey": "@nextKey", "updateKey": "@updateKey", "witnessKey": "@witnessKey", + "innkeeperKey": "@innkeeper", } +@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] @@ -67,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: @@ -147,14 +298,34 @@ 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) + diff --git a/webvh/webvh/did/witness.py b/webvh/webvh/did/witness.py index f2f88d987..3ba08294d 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, get_server_domain +from ..config.config import get_plugin_config, set_config -from .exceptions import WitnessError +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 +from ..did.utils import add_proof, url_to_domain +from ..did.key_chain import KeyChainManager +from ..did.connection import WebVHConnectionManager LOGGER = logging.getLogger(__name__) @@ -42,47 +37,122 @@ 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 key_alias(self) -> str: - """Derive witness key alias.""" - domain = await get_server_domain(self.profile) - return f"webvh:{domain}@witnessKey" + async def configure(self, config: dict = None) -> dict: + """Configure this agent as a witness. - async def connection_alias(self) -> str: - """Derive witness connection alias.""" - domain = await get_server_domain(self.profile) - return f"webvh:{domain}@witness" - - async def _get_active_witness_connection(self) -> Optional[ConnRecord]: - """Find active witness connection.""" - witness_alias = await self.connection_alias() - async with self.profile.session() as session: - connection_records = await ConnRecord.retrieve_by_alias( - session, witness_alias - ) + This creates the witness key, invitation, and updates the config. + Can be called from the configuration endpoint or during startup. + + Args: + config: Configuration dict. If None, will be fetched from profile. + + 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): + return config + + # 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) - active_connections = [ - conn for conn in connection_records if conn.state == "active" - ] + return config - if len(active_connections) > 0: - return active_connections[0] + async def key_setup(self, witness_id: str = None) -> str: + """Set up witness key and return witness_id. - return None + If witness_id is provided, binds that key. + Otherwise, finds existing key or creates a new one. - async def get_witness_key(self) -> str: - """Return the witness key.""" - witness_alias = await 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.") + Args: + witness_id: Optional witness DID (e.g., "did:key:...") - return witness_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", 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 + + async def invitation_setup(self, witness_id: str) -> str: + """Create witness invitation and return invitation URL. + + Args: + witness_id: The witness DID (e.g., "did:key:...") + + Returns: + The invitation URL string + """ + invitation_record = await self.witness_connection.create_witness_invitation( + witness_id=witness_id, + alias=None, + label="Witness Service", + multi_use=True, + ) + 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: + """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 sign(self, document: dict) -> dict: + """Sign a document with the witness key. + + Args: + document: The document to sign (dict) + + Returns: + The signed document with proof added + + 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}", + ) async def witness_log_entry( self, @@ -97,7 +167,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 @@ -107,7 +177,21 @@ async def witness_log_entry( else: responder = self.profile.inject(BaseResponder) - witness_connection = await self._get_active_witness_connection() + config = await get_plugin_config(self.profile) + server_url = config.get("server_url") + witness_connection = await self.witness_connection.get_active_connection( + 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.") @@ -131,12 +215,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, @@ -148,7 +227,21 @@ async def witness_attested_resource( else: responder = self.profile.inject(BaseResponder) - witness_connection = await self._get_active_witness_connection() + config = await get_plugin_config(self.profile) + server_url = config.get("server_url") + witness_connection = await self.witness_connection.get_active_connection( + 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.") @@ -159,16 +252,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]: @@ -177,17 +260,12 @@ 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 # is supported - from ..did.manager import ControllerManager + from ..did.controller import ControllerManager await ControllerManager(self.profile).finish_did_operation( log_entry, witness_signature @@ -213,21 +291,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( @@ -241,22 +309,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..99f2a702b 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 @@ -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..e4de210d4 --- /dev/null +++ b/webvh/webvh/protocols/events.py @@ -0,0 +1,144 @@ +"""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 591337b52..30cc84a31 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 @@ -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 73f06d9c4..2eda831a2 100644 --- a/webvh/webvh/protocols/routes.py +++ b/webvh/webvh/protocols/routes.py @@ -1,5 +1,8 @@ """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 from aiohttp import web @@ -9,24 +12,82 @@ 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.""" + + 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}. " + 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.") 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 +96,17 @@ 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}. " + 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.") record, connection_id = await PENDING_RECORDS.get_pending_record( context.profile, record_id @@ -44,24 +114,48 @@ 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 ) 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: 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 +163,17 @@ 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}. " + 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.") return web.json_response( await PENDING_RECORDS.remove_pending_record(context.profile, record_id) ) 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 76e2bf100..ab2bd9bcf 100644 --- a/webvh/webvh/routes.py +++ b/webvh/webvh/routes.py @@ -8,14 +8,18 @@ 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_plugin_config -from .did.manager import ControllerManager +from .config.config import ( + get_global_plugin_config, + get_plugin_config, + get_server_url, + set_config, +) +from .did.controller import ControllerManager from .did.exceptions import ( ConfigurationError, DidCreationError, @@ -28,7 +32,6 @@ WebvhAddVMSchema, WebvhCreateSchema, WebvhUpdateSchema, - WebvhCreateWitnessInvitationSchema, WebvhDeactivateSchema, WebvhSCIDQueryStringSchema, WebvhUpdateWhoisSchema, @@ -59,29 +62,33 @@ 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) - except (ConfigurationError, OperationError) as err: - return web.json_response({"status": "error", "message": str(err)}) + 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("/") -@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) + 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) + + if config["witness"]: + manager = WitnessManager(profile) + else: + 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)}) @docs(tags=["did-webvh"], summary="Create a did:webvh") @@ -209,13 +216,94 @@ async def update_whois(request: web.BaseRequest): def register_events(event_bus: EventBus): """Register to the acapy startup event.""" + msg = "Registering WebVH startup event handler" + 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.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) 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) + + # Skip if multitenant enabled or auto_config disabled + if profile.settings.get("multitenant.enabled"): + return + + if not config.pop("auto_config", False): + return + + if config.get("witness", False): + # Configure witness and print information + LOGGER.info("Configuring witness service...") + witness_config = await WitnessManager(profile).configure(config) + + # 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: + 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) + + +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}, wallet_name: {wallet_name}" + LOGGER.info(msg) + + if wallet_id: + msg2 = ( + f"Subwallet {wallet_id} created. " + "Configure WebVH settings via /did/webvh/configuration endpoint." + ) + LOGGER.info(msg2) async def register(app: web.Application): @@ -236,7 +324,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..b0ba72f10 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,62 @@ async def test_create(self): ) await create(self.request) + + @mock.patch("webvh.routes.WitnessManager.configure", 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_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": False}}, + ) + await on_startup_event(self.profile, mock.MagicMock()) + 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.ControllerManager.configure", new_callable=mock.AsyncMock) + async def test_on_startup_event_runs_controller_auto_setup( + 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, + "witness_id": TEST_WITNESS_INVITATION["goal"], + "auto_config": True, + } + }, + ) + await on_startup_event(self.profile, mock.MagicMock()) + 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.ControllerManager.configure", new_callable=mock.AsyncMock) + async def test_on_startup_event_runs_witness_auto_setup( + 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, + "witness": True, + "auto_config": True, + } + }, + ) + # 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_configure.await_count == 0 + mock_witness_auto.assert_awaited_once()