Repository: hyperledger/aries-cloudagent-python

Issue:
Reusable Invitations
Allow the creation of a reusable invitation. This is just like a normal invitation, but after use (receiving a request based on the invitation) the invitation is not consumed.

This is useful when a non-public-did holding entity wants an invitation intended for multiple people. This includes an invite at the end of a presentation, perhaps one on a business card, or an invitation shared with everyone at a particular conference.


Discussion:

Resolving patch:
diff --git a/aries_cloudagent/messaging/connections/manager.py b/aries_cloudagent/messaging/connections/manager.py
--- a/aries_cloudagent/messaging/connections/manager.py
+++ b/aries_cloudagent/messaging/connections/manager.py
@@ -67,6 +67,7 @@ async def create_invitation(
         their_role: str = None,
         accept: str = None,
         public: bool = False,
+        multi_use: bool = False,
     ) -> Tuple[ConnectionRecord, ConnectionInvitation]:
         """
         Generate new connection invitation.
@@ -101,6 +102,7 @@ async def create_invitation(
             their_role: a role to assign the connection
             accept: set to 'auto' to auto-accept a corresponding connection request
             public: set to True to create an invitation from the public DID
+            multi_use: set to True to create an invitation for multiple use
 
         Returns:
             A tuple of the new `ConnectionRecord` and `ConnectionInvitation` instances
@@ -119,12 +121,22 @@ async def create_invitation(
                 raise ConnectionManagerError(
                     "Cannot create public invitation with no public DID"
                 )
+
+            if multi_use:
+                raise ConnectionManagerError(
+                    "Cannot use public and multi_use at the same time"
+                )
+
             # FIXME - allow ledger instance to format public DID with prefix?
             invitation = ConnectionInvitation(
                 label=my_label, did=f"did:sov:{public_did.did}"
             )
             return None, invitation
 
+        invitation_mode = ConnectionRecord.INVITATION_MODE_ONCE
+        if multi_use:
+            invitation_mode = ConnectionRecord.INVITATION_MODE_MULTI
+
         if not my_endpoint:
             my_endpoint = self.context.settings.get("default_endpoint")
         if not accept and self.context.settings.get("debug.auto_accept_requests"):
@@ -140,6 +152,7 @@ async def create_invitation(
             their_role=their_role,
             state=ConnectionRecord.STATE_INVITATION,
             accept=accept,
+            invitation_mode=invitation_mode
         )
 
         await connection.save(self.context, reason="Created new invitation")
@@ -318,6 +331,24 @@ async def receive_request(
                 self.context, "Found invitation", {"invitation": invitation}
             )
 
+            if connection.is_multiuse_invitation:
+                wallet: BaseWallet = await self.context.inject(BaseWallet)
+                my_info = await wallet.create_local_did()
+                new_connection = ConnectionRecord(
+                    initiator=ConnectionRecord.INITIATOR_MULTIUSE,
+                    invitation_key=connection_key,
+                    my_did=my_info.did,
+                    state=ConnectionRecord.STATE_INVITATION,
+                    accept=connection.accept,
+                    their_role=connection.their_role,
+                )
+
+                await new_connection.save(
+                    self.context,
+                    reason="Received connection request from multi-use invitation DID"
+                )
+                connection = new_connection
+
         conn_did_doc = request.connection.did_doc
         if not conn_did_doc:
             raise ConnectionManagerError(
diff --git a/aries_cloudagent/messaging/connections/models/connection_record.py b/aries_cloudagent/messaging/connections/models/connection_record.py
--- a/aries_cloudagent/messaging/connections/models/connection_record.py
+++ b/aries_cloudagent/messaging/connections/models/connection_record.py
@@ -41,6 +41,7 @@ class Meta:
 
     INITIATOR_SELF = "self"
     INITIATOR_EXTERNAL = "external"
+    INITIATOR_MULTIUSE = "multiuse"
 
     STATE_INIT = "init"
     STATE_INVITATION = "invitation"
@@ -50,6 +51,9 @@ class Meta:
     STATE_ERROR = "error"
     STATE_INACTIVE = "inactive"
 
+    INVITATION_MODE_ONCE = "once"
+    INVITATION_MODE_MULTI = "multi"
+
     ROUTING_STATE_NONE = "none"
     ROUTING_STATE_REQUEST = "request"
     ROUTING_STATE_ACTIVE = "active"
@@ -74,6 +78,7 @@ def __init__(
         error_msg: str = None,
         routing_state: str = None,
         accept: str = None,
+        invitation_mode: str = None,
         **kwargs,
     ):
         """Initialize a new ConnectionRecord."""
@@ -89,6 +94,7 @@ def __init__(
         self.inbound_connection_id = inbound_connection_id
         self.routing_state = routing_state or self.ROUTING_STATE_NONE
         self.accept = accept or self.ACCEPT_MANUAL
+        self.invitation_mode = invitation_mode or self.INVITATION_MODE_ONCE
 
     @property
     def connection_id(self) -> str:
@@ -116,6 +122,7 @@ def record_tags(self) -> dict:
                 "state",
                 "routing_state",
                 "accept",
+                "invitation_mode"
             )
         }
 
@@ -339,6 +346,11 @@ def is_ready(self) -> str:
         """Accessor for connection readiness."""
         return self.state == self.STATE_ACTIVE or self.state == self.STATE_RESPONSE
 
+    @property
+    def is_multiuse_invitation(self) -> bool:
+        """Accessor for multi use invitation mode."""
+        return self.invitation_mode == self.INVITATION_MODE_MULTI
+
     async def post_save(self, context: InjectionContext, *args, **kwargs):
         """Perform post-save actions.
 
@@ -372,3 +384,4 @@ class Meta:
     routing_state = fields.Str(required=False)
     accept = fields.Str(required=False)
     error_msg = fields.Str(required=False)
+    invitation_mode = fields.Str(required=False)
diff --git a/aries_cloudagent/messaging/connections/routes.py b/aries_cloudagent/messaging/connections/routes.py
--- a/aries_cloudagent/messaging/connections/routes.py
+++ b/aries_cloudagent/messaging/connections/routes.py
@@ -176,13 +176,14 @@ async def connections_create_invitation(request: web.BaseRequest):
     context = request.app["request_context"]
     accept = request.query.get("accept")
     public = request.query.get("public")
+    multi_use = request.query.get("multi_use")
 
     if public and not context.settings.get("public_invites"):
         raise web.HTTPForbidden()
 
     connection_mgr = ConnectionManager(context)
     connection, invitation = await connection_mgr.create_invitation(
-        accept=accept, public=bool(public)
+        accept=accept, public=bool(public), multi_use=bool(multi_use)
     )
     result = {
         "connection_id": connection and connection.connection_id,


Test patch:
diff --git a/aries_cloudagent/messaging/connections/tests/test_connection_record.py b/aries_cloudagent/messaging/connections/tests/test_connection_record.py
--- a/aries_cloudagent/messaging/connections/tests/test_connection_record.py
+++ b/aries_cloudagent/messaging/connections/tests/test_connection_record.py
@@ -64,3 +64,21 @@ async def test_request_is_not_ready(self):
         fetched = await ConnectionRecord.retrieve_by_id(self.context, record_id)
 
         assert fetched.is_ready is False
+
+    async def test_invitation_is_not_multi_use(self):
+        record = ConnectionRecord(
+            my_did=self.test_did, state=ConnectionRecord.STATE_INVITATION, invitation_mode=ConnectionRecord.INVITATION_MODE_ONCE
+        )
+        record_id = await record.save(self.context)
+        fetched = await ConnectionRecord.retrieve_by_id(self.context, record_id)
+
+        assert fetched.is_multiuse_invitation is False
+
+    async def test_invitation_is_multi_use(self):
+        record = ConnectionRecord(
+            my_did=self.test_did, state=ConnectionRecord.STATE_INVITATION,invitation_mode=ConnectionRecord.INVITATION_MODE_MULTI
+        )
+        record_id = await record.save(self.context)
+        fetched = await ConnectionRecord.retrieve_by_id(self.context, record_id)
+
+        assert fetched.is_multiuse_invitation is True
diff --git a/aries_cloudagent/messaging/connections/tests/test_manager.py b/aries_cloudagent/messaging/connections/tests/test_manager.py
new file mode 100644
--- /dev/null
+++ b/aries_cloudagent/messaging/connections/tests/test_manager.py
@@ -0,0 +1,117 @@
+from asynctest import TestCase as AsyncTestCase
+
+from aries_cloudagent.messaging.connections.messages.connection_request import ConnectionRequest
+from aries_cloudagent.messaging.connections.models.connection_detail import ConnectionDetail
+from aries_cloudagent.messaging.connections.models.diddoc import DIDDoc, PublicKey, PublicKeyType, Service
+from aries_cloudagent.messaging.message_delivery import MessageDelivery
+from aries_cloudagent.wallet.base import BaseWallet
+from aries_cloudagent.wallet.basic import BasicWallet
+from ....config.injection_context import InjectionContext
+from ..models.connection_record import ConnectionRecord
+from ....storage.base import BaseStorage
+from ....storage.basic import BasicStorage
+
+from ..manager import ConnectionManager, ConnectionManagerError
+
+
+class TestConfig:
+
+    test_seed = "testseed000000000000000000000001"
+    test_did = "55GkHamhTU1ZbTbV2ab9DE"
+    test_verkey = "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx"
+    test_endpoint = "http://localhost"
+
+    test_target_did = "GbuDUYXaUZRfHD2jeDuQuP"
+    test_target_verkey = "9WCgWKUaAJj3VWxxtzvvMQN3AoFxoBtBDo9ntwJnVVCC"
+
+
+    def make_did_doc(self, did, verkey):
+        doc = DIDDoc(did=did)
+        controller = did
+        ident = "1"
+        pk_value = verkey
+        pk = PublicKey(
+            did,
+            ident,
+            pk_value,
+            PublicKeyType.ED25519_SIG_2018,
+            controller,
+            False,
+        )
+        doc.set(pk)
+        recip_keys = [pk]
+        router_keys = []
+        service = Service(
+            did,
+            "indy",
+            "IndyAgent",
+            recip_keys,
+            router_keys,
+            self.test_endpoint,
+        )
+        doc.set(service)
+        return doc
+
+
+class TestConnectionManager(AsyncTestCase, TestConfig):
+    def setUp(self):
+        self.storage = BasicStorage()
+        self.context = InjectionContext()
+        self.context.injector.bind_instance(BaseStorage, self.storage)
+        self.context.injector.bind_instance(BaseWallet, BasicWallet())
+        self.manager = ConnectionManager(self.context)
+        self.test_info = ConnectionRecord(
+            my_did=self.test_did,
+            their_did=self.test_target_did,
+            their_role=None,
+            state=ConnectionRecord.STATE_ACTIVE,
+        )
+
+    async def test_public_and_multi_use_fails(self):
+        ci_awaitable = self.manager.create_invitation(public=True, multi_use=True)
+        await self.assertAsyncRaises(ConnectionManagerError, ci_awaitable)
+
+    async def test_non_multi_use_invitation_fails_on_reuse(self):
+        connect_record, connect_invite = await self.manager.create_invitation(my_endpoint="testendpoint")
+
+        delivery = MessageDelivery(recipient_verkey=connect_record.invitation_key)
+
+        requestA = ConnectionRequest(
+            connection=ConnectionDetail(did=self.test_target_did,
+                                        did_doc=self.make_did_doc(self.test_target_did, self.test_target_verkey)),
+            label="SameInviteRequestA",
+        )
+
+        await self.manager.receive_request(requestA, delivery)
+
+        requestB = ConnectionRequest(
+            connection=ConnectionDetail(did=self.test_did, did_doc=self.make_did_doc(self.test_did, self.test_verkey)),
+            label="SameInviteRequestB",
+        )
+
+        # requestB fails because the invitation was not set to multi-use
+        rr_awaitable = self.manager.receive_request(requestB, delivery)
+        await self.assertAsyncRaises(ConnectionManagerError, rr_awaitable)
+
+    async def test_multi_use_invitation(self):
+        connect_record, connect_invite = await self.manager.create_invitation(my_endpoint="testendpoint", multi_use=True)
+
+        delivery = MessageDelivery(recipient_verkey=connect_record.invitation_key)
+
+        requestA = ConnectionRequest(
+            connection=ConnectionDetail(did=self.test_target_did,
+                                        did_doc=self.make_did_doc(self.test_target_did, self.test_target_verkey)),
+            label="SameInviteRequestA",
+        )
+
+        await self.manager.receive_request(requestA, delivery)
+
+        requestB = ConnectionRequest(
+            connection=ConnectionDetail(did=self.test_did, did_doc=self.make_did_doc(self.test_did, self.test_verkey)),
+            label="SameInviteRequestB",
+        )
+
+        await self.manager.receive_request(requestB, delivery)
+
+
+