From 89120bec2bb3b663494f6bf3746038361c5f8ae6 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:35:56 -0400 Subject: [PATCH] Feature multikey management (#3246) * load key plugin Signed-off-by: Patrick * simplify functions Signed-off-by: Patrick * added option kid field to KeyInfo Signed-off-by: PatStLouis * improving linting and unit tests Signed-off-by: PatStLouis * add empty kid value for bbs tests Signed-off-by: PatStLouis * linting fix Signed-off-by: PatStLouis * more linting Signed-off-by: PatStLouis * add 2 more tests Signed-off-by: PatStLouis * linting Signed-off-by: PatStLouis * await function in tests Signed-off-by: PatStLouis * askar support Signed-off-by: PatStLouis * implement askar Signed-off-by: PatStLouis * spelling mistake Signed-off-by: PatStLouis * use a constant for default algorithm Signed-off-by: PatStLouis * linting Signed-off-by: PatStLouis * remove unused code Signed-off-by: PatStLouis * formatting Signed-off-by: PatStLouis * linting Signed-off-by: PatStLouis * fix key by kid Signed-off-by: PatStLouis * fix unit tests Signed-off-by: PatStLouis * add type hints and pass session to call functions instead of profile Signed-off-by: PatStLouis * remove manager from test function instanciation Signed-off-by: PatStLouis * replace inject_or with inject for providing wallet interface Signed-off-by: PatStLouis * move wallet injection to class initialization step Signed-off-by: PatStLouis --------- Signed-off-by: Patrick Signed-off-by: PatStLouis --- aries_cloudagent/config/default_context.py | 1 + .../vc/tests/test_bbs_mattr_interop.py | 1 + aries_cloudagent/wallet/askar.py | 10 +- aries_cloudagent/wallet/did_info.py | 13 +- aries_cloudagent/wallet/in_memory.py | 4 + aries_cloudagent/wallet/keys/__init__.py | 0 aries_cloudagent/wallet/keys/manager.py | 110 +++++++++ aries_cloudagent/wallet/keys/routes.py | 226 ++++++++++++++++++ .../wallet/keys/tests/test_key_operations.py | 46 ++++ 9 files changed, 403 insertions(+), 8 deletions(-) create mode 100644 aries_cloudagent/wallet/keys/__init__.py create mode 100644 aries_cloudagent/wallet/keys/manager.py create mode 100644 aries_cloudagent/wallet/keys/routes.py create mode 100644 aries_cloudagent/wallet/keys/tests/test_key_operations.py diff --git a/aries_cloudagent/config/default_context.py b/aries_cloudagent/config/default_context.py index c012b676a7..e2e4dc15b4 100644 --- a/aries_cloudagent/config/default_context.py +++ b/aries_cloudagent/config/default_context.py @@ -140,6 +140,7 @@ async def load_plugins(self, context: InjectionContext): plugin_registry.register_plugin("aries_cloudagent.settings") plugin_registry.register_plugin("aries_cloudagent.vc") plugin_registry.register_plugin("aries_cloudagent.wallet") + plugin_registry.register_plugin("aries_cloudagent.wallet.keys") anoncreds_plugins = [ "aries_cloudagent.anoncreds", diff --git a/aries_cloudagent/vc/tests/test_bbs_mattr_interop.py b/aries_cloudagent/vc/tests/test_bbs_mattr_interop.py index d893f94f70..d5bae2dfa6 100644 --- a/aries_cloudagent/vc/tests/test_bbs_mattr_interop.py +++ b/aries_cloudagent/vc/tests/test_bbs_mattr_interop.py @@ -47,6 +47,7 @@ async def asyncSetUp(self): "verkey": public_key_base58, "metadata": {}, "key_type": BLS12381G2, + "kid": None, } self.signature_issuer_suite = BbsBlsSignature2020( diff --git a/aries_cloudagent/wallet/askar.py b/aries_cloudagent/wallet/askar.py index efea7120bb..d0ade2508d 100644 --- a/aries_cloudagent/wallet/askar.py +++ b/aries_cloudagent/wallet/askar.py @@ -114,8 +114,7 @@ async def create_key( "Verification key already present in wallet" ) from None raise WalletError("Error creating signing key") from err - - return KeyInfo(verkey=verkey, metadata=metadata, key_type=key_type) + return KeyInfo(verkey=verkey, metadata=metadata, key_type=key_type, kid=kid) async def assign_kid_to_key(self, verkey: str, kid: str) -> KeyInfo: """Assign a KID to a key. @@ -143,7 +142,7 @@ async def assign_kid_to_key(self, verkey: str, kid: str) -> KeyInfo: raise WalletError(f"Unknown key type {key.algorithm.value}") await self._session.handle.update_key(name=verkey, tags={"kid": kid}) - return KeyInfo(verkey=verkey, metadata=metadata, key_type=key_type) + return KeyInfo(verkey=verkey, metadata=metadata, key_type=key_type, kid=kid) async def get_key_by_kid(self, kid: str) -> KeyInfo: """Fetch a key by looking up its kid. @@ -170,7 +169,7 @@ async def get_key_by_kid(self, kid: str) -> KeyInfo: if not key_type: raise WalletError(f"Unknown key type {key.algorithm.value}") - return KeyInfo(verkey=verkey, metadata=metadata, key_type=key_type) + return KeyInfo(verkey=verkey, metadata=metadata, key_type=key_type, kid=kid) async def get_signing_key(self, verkey: str) -> KeyInfo: """Fetch info for a signing keypair. @@ -194,7 +193,8 @@ async def get_signing_key(self, verkey: str) -> KeyInfo: raise WalletNotFoundError("Unknown key: {}".format(verkey)) metadata = json.loads(key.metadata or "{}") # FIXME implement key types - return KeyInfo(verkey=verkey, metadata=metadata, key_type=ED25519) + kid = key.tags["kid"] if "kid" in key.tags else None + return KeyInfo(verkey=verkey, metadata=metadata, key_type=ED25519, kid=kid) async def replace_signing_key_metadata(self, verkey: str, metadata: dict): """Replace the metadata associated with a signing keypair. diff --git a/aries_cloudagent/wallet/did_info.py b/aries_cloudagent/wallet/did_info.py index 3ef891a966..e3a539616b 100644 --- a/aries_cloudagent/wallet/did_info.py +++ b/aries_cloudagent/wallet/did_info.py @@ -7,9 +7,16 @@ INVITATION_REUSE_KEY = "invitation_reuse" -KeyInfo = NamedTuple( - "KeyInfo", [("verkey", str), ("metadata", dict), ("key_type", KeyType)] -) + +class KeyInfo(NamedTuple): + """Class returning key information.""" + + verkey: str + metadata: dict + key_type: KeyType + kid: str = None + + DIDInfo = NamedTuple( "DIDInfo", [ diff --git a/aries_cloudagent/wallet/in_memory.py b/aries_cloudagent/wallet/in_memory.py index cbd1f00960..f798e2264a 100644 --- a/aries_cloudagent/wallet/in_memory.py +++ b/aries_cloudagent/wallet/in_memory.py @@ -95,6 +95,7 @@ async def create_key( verkey=verkey_enc, metadata=self.profile.keys[verkey_enc]["metadata"].copy(), key_type=key_type, + kid=kid, ) async def assign_kid_to_key(self, verkey: str, kid: str) -> KeyInfo: @@ -120,6 +121,7 @@ async def assign_kid_to_key(self, verkey: str, kid: str) -> KeyInfo: verkey=key["verkey"], metadata=key["metadata"].copy(), key_type=key["key_type"], + kid=kid, ) async def get_key_by_kid(self, kid: str) -> KeyInfo: @@ -138,6 +140,7 @@ async def get_key_by_kid(self, kid: str) -> KeyInfo: verkey=key["verkey"], metadata=key["metadata"].copy(), key_type=key["key_type"], + kid=key["kid"], ) raise WalletNotFoundError(f"Key not found with kid {kid}") @@ -162,6 +165,7 @@ async def get_signing_key(self, verkey: str) -> KeyInfo: verkey=key["verkey"], metadata=key["metadata"].copy(), key_type=key["key_type"], + kid=key["kid"], ) async def replace_signing_key_metadata(self, verkey: str, metadata: dict): diff --git a/aries_cloudagent/wallet/keys/__init__.py b/aries_cloudagent/wallet/keys/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/wallet/keys/manager.py b/aries_cloudagent/wallet/keys/manager.py new file mode 100644 index 0000000000..3bbbba245b --- /dev/null +++ b/aries_cloudagent/wallet/keys/manager.py @@ -0,0 +1,110 @@ +"""Multikey class.""" + +from ...core.profile import ProfileSession +from ..base import BaseWallet +from ..key_type import ED25519 +from ..util import b58_to_bytes, bytes_to_b58 +from ...utils.multiformats import multibase +from ...wallet.error import WalletNotFoundError + +DEFAULT_ALG = "ed25519" +ALG_MAPPINGS = { + "ed25519": {"key_type": ED25519, "prefix_hex": "ed01", "prefix_length": 2} +} + + +class MultikeyManagerError(Exception): + """Generic MultikeyManager Error.""" + + +class MultikeyManager: + """Class for managing wallet keys.""" + + def __init__(self, session: ProfileSession): + """Initialize the MultikeyManager.""" + + self.wallet: BaseWallet = session.inject(BaseWallet) + + def _multikey_to_verkey(self, multikey: str, alg: str = DEFAULT_ALG): + """Transform multikey to verkey.""" + + prefix_length = ALG_MAPPINGS[alg]["prefix_length"] + public_bytes = bytes(bytearray(multibase.decode(multikey))[prefix_length:]) + + return bytes_to_b58(public_bytes) + + def _verkey_to_multikey(self, verkey: str, alg: str = DEFAULT_ALG): + """Transform verkey to multikey.""" + + prefix_hex = ALG_MAPPINGS[alg]["prefix_hex"] + prefixed_key_hex = f"{prefix_hex}{b58_to_bytes(verkey).hex()}" + + return multibase.encode(bytes.fromhex(prefixed_key_hex), "base58btc") + + async def kid_exists(self, kid: str): + """Check if kid exists.""" + + try: + key = await self.wallet.get_key_by_kid(kid=kid) + + if key: + return True + + except (WalletNotFoundError, AttributeError): + return False + + async def from_kid(self, kid: str): + """Fetch a single key.""" + + key_info = await self.wallet.get_key_by_kid(kid=kid) + + return { + "kid": key_info.kid, + "multikey": self._verkey_to_multikey(key_info.verkey), + } + + async def from_multikey(self, multikey: str): + """Fetch a single key.""" + + key_info = await self.wallet.get_signing_key( + verkey=self._multikey_to_verkey(multikey) + ) + + return { + "kid": key_info.kid, + "multikey": self._verkey_to_multikey(key_info.verkey), + } + + async def create(self, seed: str = None, kid: str = None, alg: str = DEFAULT_ALG): + """Create a new key pair.""" + + if alg not in ALG_MAPPINGS: + raise MultikeyManagerError( + f"Unknown key algorithm, use one of {list(ALG_MAPPINGS.keys())}." + ) + + if kid and await self.kid_exists(kid=kid): + raise MultikeyManagerError(f"kid '{kid}' already exists in wallet.") + + key_type = ALG_MAPPINGS[alg]["key_type"] + key_info = await self.wallet.create_key(key_type=key_type, seed=seed, kid=kid) + + return { + "kid": key_info.kid, + "multikey": self._verkey_to_multikey(key_info.verkey), + } + + async def update(self, multikey: str, kid: str): + """Assign a new kid to a key pair.""" + + if kid and await self.kid_exists(kid=kid): + raise MultikeyManagerError(f"kid '{kid}' already exists in wallet.") + + key_info = await self.wallet.assign_kid_to_key( + verkey=self._multikey_to_verkey(multikey), kid=kid + ) + + return { + "kid": key_info.kid, + "multikey": self._verkey_to_multikey(key_info.verkey), + } diff --git a/aries_cloudagent/wallet/keys/routes.py b/aries_cloudagent/wallet/keys/routes.py new file mode 100644 index 0000000000..dd6d097087 --- /dev/null +++ b/aries_cloudagent/wallet/keys/routes.py @@ -0,0 +1,226 @@ +"""Key admin routes.""" + +import logging + +from aiohttp import web +from aiohttp_apispec import docs, request_schema, response_schema +from marshmallow import fields + +from ...admin.decorators.auth import tenant_authentication +from ...admin.request_context import AdminRequestContext +from ...messaging.models.openapi import OpenAPISchema +from .manager import MultikeyManager, MultikeyManagerError, DEFAULT_ALG +from ...wallet.error import WalletDuplicateError, WalletNotFoundError + +LOGGER = logging.getLogger(__name__) + + +class CreateKeyRequestSchema(OpenAPISchema): + """Request schema for creating a new key.""" + + alg = fields.Str( + required=False, + metadata={ + "description": "Which key algorithm to use.", + "example": DEFAULT_ALG, + }, + ) + + seed = fields.Str( + required=False, + metadata={ + "description": "Optional seed to generate the key pair. \ + Must enable insecure wallet mode.", + "example": "00000000000000000000000000000000", + }, + ) + + kid = fields.Str( + required=False, + metadata={ + "description": "Optional kid to bind to the keypair, \ + such as a verificationMethod.", + "example": "did:web:example.com#key-01", + }, + ) + + +class CreateKeyResponseSchema(OpenAPISchema): + """Response schema from creating a new key.""" + + multikey = fields.Str( + metadata={ + "description": "The Public Key Multibase format (multikey)", + "example": "z6MkgKA7yrw5kYSiDuQFcye4bMaJpcfHFry3Bx45pdWh3s8i", + }, + ) + + kid = fields.Str( + metadata={ + "description": "The associated kid", + "example": "did:web:example.com#key-01", + }, + ) + + +class UpdateKeyRequestSchema(OpenAPISchema): + """Request schema for updating an existing key pair.""" + + multikey = fields.Str( + required=True, + metadata={ + "description": "Multikey of the key pair to update", + "example": "z6MkgKA7yrw5kYSiDuQFcye4bMaJpcfHFry3Bx45pdWh3s8i", + }, + ) + + kid = fields.Str( + required=True, + metadata={ + "description": "New kid to bind to the key pair, \ + such as a verificationMethod.", + "example": "did:web:example.com#key-02", + }, + ) + + +class UpdateKeyResponseSchema(OpenAPISchema): + """Response schema from updating an existing key pair.""" + + multikey = fields.Str( + metadata={ + "description": "The Public Key Multibase format (multikey)", + "example": "z6MkgKA7yrw5kYSiDuQFcye4bMaJpcfHFry3Bx45pdWh3s8i", + }, + ) + + kid = fields.Str( + metadata={ + "description": "The associated kid", + "example": "did:web:example.com#key-02", + }, + ) + + +class FetchKeyResponseSchema(OpenAPISchema): + """Response schema from updating an existing key pair.""" + + multikey = fields.Str( + metadata={ + "description": "The Public Key Multibase format (multikey)", + "example": "z6MkgKA7yrw5kYSiDuQFcye4bMaJpcfHFry3Bx45pdWh3s8i", + }, + ) + + kid = fields.Str( + metadata={ + "description": "The associated kid", + "example": "did:web:example.com#key-01", + }, + ) + + +@docs(tags=["wallet"], summary="Fetch key info.") +@response_schema(FetchKeyResponseSchema, 200, description="") +@tenant_authentication +async def fetch_key(request: web.BaseRequest): + """Request handler for fetching a key. + + Args: + request: aiohttp request object + + """ + context: AdminRequestContext = request["context"] + multikey = request.match_info["multikey"] + + try: + async with context.session() as session: + key_info = await MultikeyManager(session).from_multikey(multikey=multikey) + return web.json_response( + key_info, + status=200, + ) + + except (MultikeyManagerError, WalletDuplicateError, WalletNotFoundError) as err: + return web.json_response({"message": str(err)}, status=400) + + +@docs(tags=["wallet"], summary="Create a key pair") +@request_schema(CreateKeyRequestSchema()) +@response_schema(CreateKeyResponseSchema, 200, description="") +@tenant_authentication +async def create_key(request: web.BaseRequest): + """Request handler for creating a new key pair in the wallet. + + Args: + request: aiohttp request object + + Returns: + The Public Key Multibase format (multikey) + + """ + context: AdminRequestContext = request["context"] + body = await request.json() + + seed = body.get("seed") or None + kid = body.get("kid") or None + alg = body.get("alg") or DEFAULT_ALG + + if seed and not context.settings.get("wallet.allow_insecure_seed"): + raise MultikeyManagerError("Seed support is not enabled.") + + try: + async with context.session() as session: + key_info = await MultikeyManager(session).create(seed=seed, kid=kid, alg=alg) + return web.json_response( + key_info, + status=201, + ) + except (MultikeyManagerError, WalletDuplicateError, WalletNotFoundError) as err: + return web.json_response({"message": str(err)}, status=400) + + +@docs(tags=["wallet"], summary="Update a key pair's kid") +@request_schema(UpdateKeyRequestSchema()) +@response_schema(UpdateKeyResponseSchema, 200, description="") +@tenant_authentication +async def update_key(request: web.BaseRequest): + """Request handler for creating a new key pair in the wallet. + + Args: + request: aiohttp request object + + Returns: + The Public Key Multibase format (multikey) + + """ + context: AdminRequestContext = request["context"] + body = await request.json() + + multikey = body.get("multikey") + kid = body.get("kid") + + try: + async with context.session() as session: + key_info = await MultikeyManager(session).update( + multikey=multikey, + kid=kid, + ) + return web.json_response( + key_info, + status=200, + ) + except (MultikeyManagerError, WalletDuplicateError, WalletNotFoundError) as err: + return web.json_response({"message": str(err)}, status=400) + + +async def register(app: web.Application): + """Register routes.""" + + app.add_routes( + [ + web.get("/wallet/keys/{multikey}", fetch_key, allow_head=False), + web.post("/wallet/keys", create_key), + web.put("/wallet/keys", update_key), + ] + ) diff --git a/aries_cloudagent/wallet/keys/tests/test_key_operations.py b/aries_cloudagent/wallet/keys/tests/test_key_operations.py new file mode 100644 index 0000000000..f38319a065 --- /dev/null +++ b/aries_cloudagent/wallet/keys/tests/test_key_operations.py @@ -0,0 +1,46 @@ +"""Test MultikeypManager.""" + +from unittest import IsolatedAsyncioTestCase +from aries_cloudagent.wallet.keys.manager import MultikeyManager +from aries_cloudagent.core.in_memory import InMemoryProfile + + +class TestKeyOperations(IsolatedAsyncioTestCase): + profile = InMemoryProfile.test_profile() + seed = "00000000000000000000000000000000" + multikey = "z6MkgKA7yrw5kYSiDuQFcye4bMaJpcfHFry3Bx45pdWh3s8i" + verkey = "2ru5PcgeQzxF7QZYwQgDkG2K13PRqyigVw99zMYg8eML" + kid = "did:web:example.com#key-01" + + async def test_key_creation(self): + async with self.profile.session() as session: + key_info = await MultikeyManager(session=session).create(seed=self.seed) + assert key_info["multikey"] == self.multikey + assert key_info["kid"] is None + + key_info = await MultikeyManager(session=session).from_multikey( + multikey=self.multikey + ) + assert key_info["multikey"] == self.multikey + assert key_info["kid"] is None + + key_info = await MultikeyManager(session=session).update( + multikey=self.multikey, kid=self.kid + ) + assert key_info["multikey"] == self.multikey + assert key_info["kid"] == self.kid + + key_info = await MultikeyManager(session=session).from_kid(kid=self.kid) + assert key_info["multikey"] == self.multikey + assert key_info["kid"] == self.kid + + async def test_key_representations(self): + async with self.profile.session() as session: + assert ( + MultikeyManager(session=session)._multikey_to_verkey(self.multikey) + == self.verkey + ) + assert ( + MultikeyManager(session=session)._verkey_to_multikey(self.verkey) + == self.multikey + )