diff --git a/src/keria/app/aiding.py b/src/keria/app/aiding.py index 9340a9a1..ebc8054c 100644 --- a/src/keria/app/aiding.py +++ b/src/keria/app/aiding.py @@ -51,6 +51,8 @@ def loadEnds(app, agency, authn): app.add_route("/challenges", chaEnd) chaResEnd = ChallengeResourceEnd() app.add_route("/challenges/{name}", chaResEnd) + chaVerResEnd = ChallengeVerifyResourceEnd() + app.add_route("/challenges/{name}/verify/{source}", chaVerResEnd) contactColEnd = ContactCollectionEnd() app.add_route("/contacts", contactColEnd) @@ -819,7 +821,7 @@ def on_get(req, rep): rep: falcon.Response HTTP response --- - summary: Get list of agent identifiers + summary: Get random list of words for a 2 factor auth challenge description: Get the list of identifiers associated with this agent tags: - Challenge/Response @@ -832,7 +834,7 @@ def on_get(req, rep): required: false responses: 200: - description: An array of Identifier key state information + description: An array of random words content: application/json: schema: @@ -919,14 +921,79 @@ def on_post(req, rep, name): rep.status = falcon.HTTP_202 + +class ChallengeVerifyResourceEnd: + """ Resource for Challenge/Response Verification Endpoints """ + + @staticmethod + def on_post(req, rep, name, source): + """ Challenge POST endpoint + + Parameters: + req: falcon.Request HTTP request + rep: falcon.Response HTTP response + name: human readable name of identifier to use to sign the challange/response + source: qb64 AID of of source of signed response to verify + + --- + summary: Sign challange message and forward to peer identfiier + description: Sign a challenge word list received out of bands and send `exn` peer to peer message + to recipient + tags: + - Challenge/Response + parameters: + - in: path + name: name + schema: + type: string + required: true + description: Human readable alias for the identifier to create + requestBody: + required: true + content: + application/json: + schema: + description: Challenge response + properties: + recipient: + type: string + description: human readable alias recipient identifier to send signed challenge to + words: + type: array + description: challenge in form of word list + items: + type: string + responses: + 202: + description: Success submission of signed challenge/response + """ + agent = req.context.agent + hab = agent.hby.habByName(name) + if hab is None: + raise falcon.HTTPNotFound(description="no matching Hab for alias {name}") + + body = req.get_media() + words = httping.getRequiredParam(body, "words") + if source not in agent.hby.kevers: + raise falcon.HTTPNotFound(description=f"challenge response source={source} not found") + + meta = dict(words=words) + op = agent.monitor.submit(source, longrunning.OpTypes.challenge, metadata=meta) + rep.status = falcon.HTTP_202 + rep.content_type = "application/json" + rep.data = op.to_json().encode("utf-8") + + rep.status = falcon.HTTP_202 + @staticmethod - def on_put(req, rep, name): + def on_put(req, rep, name, source): """ Challenge PUT accept endpoint Parameters: req: falcon.Request HTTP request rep: falcon.Response HTTP response name: human readable name of identifier to use to sign the challange/response + source: qb64 AID of of source of signed response to verify --- summary: Mark challenge response exn message as signed @@ -962,16 +1029,18 @@ def on_put(req, rep, name): agent = req.context.agent hab = agent.hby.habByName(name) if hab is None: - raise falcon.HTTPBadRequest(description="no matching Hab for alias {name}") + raise falcon.HTTPNotFound(description="no matching Hab for alias {name}") body = req.get_media() - if "aid" not in body or "said" not in body: + if "said" not in body: raise falcon.HTTPBadRequest(description="challenge response acceptance requires 'aid' and 'said'") - aid = body["aid"] + if source not in agent.hby.kevers: + raise falcon.HTTPNotFound(description=f"challenge response source={source} not found") + said = body["said"] saider = coring.Saider(qb64=said) - agent.hby.db.chas.add(keys=(aid,), val=saider) + agent.hby.db.chas.add(keys=(source,), val=saider) rep.status = falcon.HTTP_202 diff --git a/src/keria/core/longrunning.py b/src/keria/core/longrunning.py index 8832ec7d..daff6313 100644 --- a/src/keria/core/longrunning.py +++ b/src/keria/core/longrunning.py @@ -17,10 +17,10 @@ from keri.help import helping # long running operationt types -Typeage = namedtuple("Tierage", 'oobi witness delegation group query registry credential endrole done') +Typeage = namedtuple("Tierage", 'oobi witness delegation group query registry credential endrole challenge done') OpTypes = Typeage(oobi="oobi", witness='witness', delegation='delegation', group='group', query='query', - registry='registry', credential='credential', endrole='endrole', done='done') + registry='registry', credential='credential', endrole='endrole', challenge='challenge', done='done') @dataclass_json @@ -330,6 +330,29 @@ def status(self, op): else: operation.done = False + elif op.type in (OpTypes.challenge,): + if op.oid not in self.hby.kevers: + operation.done = False + + if "words" not in op.metadata: + raise kering.ValidationError( + f"invalid long running {op.type} operaiton, metadata missing 'ced' field") + + found = False + words = op.metadata["words"] + saiders = self.hby.db.reps.get(keys=(op.oid,)) + for saider in saiders: + exn = self.hby.db.exns.get(keys=(saider.qb64,)) + if words == exn.ked['a']['words']: + found = True + break + + if found: + operation.done = True + operation.response = dict(exn=exn.ked) + else: + operation.done = False + elif op.type in (OpTypes.done, ): operation.done = True operation.response = op.metadata["response"] @@ -345,9 +368,6 @@ def status(self, op): class OperationResourceEnd: """ Single Resource REST endpoint for long running operations - Attributes: - monitor(Monitor): long running operation monitor - """ @staticmethod diff --git a/tests/app/test_aiding.py b/tests/app/test_aiding.py index cda79ee1..1f0c3daf 100644 --- a/tests/app/test_aiding.py +++ b/tests/app/test_aiding.py @@ -851,6 +851,8 @@ def test_challenge_ends(helpers): app.add_route("/challenges", chaEnd) chaResEnd = aiding.ChallengeResourceEnd() app.add_route("/challenges/{name}", chaResEnd) + chaVerResEnd = aiding.ChallengeVerifyResourceEnd() + app.add_route("/challenges/{name}/verify/{source}", chaVerResEnd) client = testing.TestClient(app) @@ -895,19 +897,50 @@ def test_challenge_ends(helpers): assert result.status == falcon.HTTP_202 data = dict() - data["aid"] = "Eo6MekLECO_ZprzHwfi7wG2ubOt2DWKZQcMZvTbenBNU" b = json.dumps(data).encode("utf-8") - result = client.simulate_put(path="/challenges/henk", body=b) - assert result.status == falcon.HTTP_400 # Missing recipient + result = client.simulate_put(path="/challenges/henk/verify/Eo6MekLECO_ZprzHwfi7wG2ubOt2DWKZQcMZvTbenBNU", body=b) + assert result.status == falcon.HTTP_404 # Missing recipient b = json.dumps(data).encode("utf-8") - result = client.simulate_put(path="/challenges/pal", body=b) + result = client.simulate_put(path=f"/challenges/pal/verify/{aid['i']}", body=b) assert result.status == falcon.HTTP_400 # Missing said data["said"] = exn.said b = json.dumps(data).encode("utf-8") - result = client.simulate_put(path="/challenges/pal", body=b) + result = client.simulate_put(path=f"/challenges/pal/verify/EFt8G8gkCJ71e4amQaRUYss0BDK4pUpzKelEIr3yZ1D0", + body=b) + assert result.status == falcon.HTTP_404 # Missing said + + result = client.simulate_put(path=f"/challenges/pal/verify/{aid['i']}", body=b) + assert result.status == falcon.HTTP_202 + + data = dict( + words=words, + said=exn.said + ) + b = json.dumps(data).encode("utf-8") + result = client.simulate_post(path=f"/challenges/henk/verify/{aid['i']}", body=b) + assert result.status_code == 404 + + b = json.dumps(data).encode("utf-8") + result = client.simulate_post(path=f"/challenges/pal/verify/EFt8G8gkCJ71e4amQaRUYss0BDK4pUpzKelEIr3yZ1D0", + body=b) + assert result.status_code == 404 + + b = json.dumps(data).encode("utf-8") + result = client.simulate_post(path=f"/challenges/pal/verify/{aid['i']}", body=b) + assert result.status == falcon.HTTP_202 + op = result.json + assert op["done"] is False + + # Set the signed result to True so it verifies + agent.hby.db.reps.add(keys=(aid['i'],), val=exn.saider) + agent.hby.db.exns.pin(keys=(exn.said,), val=exn) + + result = client.simulate_post(path=f"/challenges/pal/verify/{aid['i']}", body=b) assert result.status == falcon.HTTP_202 + op = result.json + assert op["done"] is True def test_contact_ends(helpers): diff --git a/tests/app/test_specing.py b/tests/app/test_specing.py index f8ab998d..1163dea7 100644 --- a/tests/app/test_specing.py +++ b/tests/app/test_specing.py @@ -80,16 +80,16 @@ def test_spec_resource(helpers): 'to load"}], "responses": {"200": {"description": "Key event log and key ' 'state of identifier"}, "404": {"description": "Identifier not found in Key ' 'event database"}}}}, "/identifiers": {"get": {}, "options": {}, "post": {}}, ' - '"/challenges": {"get": {"summary": "Get list of agent identifiers", ' - '"description": "Get the list of identifiers associated with this agent", ' - '"tags": ["Challenge/Response"], "parameters": [{"in": "query", "name": ' - '"strength", "schema": {"type": "int"}, "description": "cryptographic ' + '"/challenges": {"get": {"summary": "Get random list of words for a 2 factor ' + 'auth challenge", "description": "Get the list of identifiers associated with ' + 'this agent", "tags": ["Challenge/Response"], "parameters": [{"in": "query", ' + '"name": "strength", "schema": {"type": "int"}, "description": "cryptographic ' 'strength of word list", "required": false}], "responses": {"200": ' - '{"description": "An array of Identifier key state information", "content": ' - '{"application/json": {"schema": {"description": "Randon word list", "type": ' - '"object", "properties": {"words": {"type": "array", "description": "random ' - 'challange word list", "items": {"type": "string"}}}}}}}}}}, "/contacts": ' - '{"get": {"summary": "Get list of contact information associated with remote ' + '{"description": "An array of random words", "content": {"application/json": ' + '{"schema": {"description": "Randon word list", "type": "object", ' + '"properties": {"words": {"type": "array", "description": "random challange ' + 'word list", "items": {"type": "string"}}}}}}}}}}, "/contacts": {"get": ' + '{"summary": "Get list of contact information associated with remote ' 'identifiers", "description": "Get list of contact information associated ' 'with remote identifiers. All information is metadata and kept in local ' 'storage only", "tags": ["Contacts"], "parameters": [{"in": "query", "name": ' @@ -136,38 +136,28 @@ def test_spec_resource(helpers): 'to send signed challenge to"}, "words": {"type": "array", "description": ' '"challenge in form of word list", "items": {"type": "string"}}}}}}}, ' '"responses": {"202": {"description": "Success submission of signed ' - 'challenge/response"}}}, "put": {"summary": "Mark challenge response exn ' - 'message as signed", "description": "Mark challenge response exn message as ' - 'signed", "tags": ["Challenge/Response"], "parameters": [{"in": "path", ' - '"name": "name", "schema": {"type": "string"}, "required": true, ' - '"description": "Human readable alias for the identifier to create"}], ' - '"requestBody": {"required": true, "content": {"application/json": {"schema": ' - '{"description": "Challenge response", "properties": {"aid": {"type": ' - '"string", "description": "aid of signer of accepted challenge response"}, ' - '"said": {"type": "array", "description": "SAID of challenge message signed", ' - '"items": {"type": "string"}}}}}}}, "responses": {"202": {"description": ' - '"Success submission of signed challenge/response"}}}}, "/contacts/{prefix}": ' - '{"delete": {"summary": "Delete contact information associated with remote ' - 'identfier", "description": "Delete contact information associated with ' - 'remote identfier", "tags": ["Contacts"], "parameters": [{"in": "path", ' - '"name": "prefix", "schema": {"type": "string"}, "required": true, ' - '"description": "qb64 identifier prefix of contact to delete"}], "responses": ' - '{"202": {"description": "Contact information successfully deleted for ' - 'prefix"}, "404": {"description": "No contact information found for ' - 'prefix"}}}, "get": {"summary": "Get contact information associated with ' - 'single remote identfier", "description": "Get contact information associated ' - 'with single remote identfier. All information is meta-data and kept in ' - 'local storage only", "tags": ["Contacts"], "parameters": [{"in": "path", ' - '"name": "prefix", "schema": {"type": "string"}, "required": true, ' - '"description": "qb64 identifier prefix of contact to get"}], "responses": ' - '{"200": {"description": "Contact information successfully retrieved for ' - 'prefix"}, "404": {"description": "No contact information found for ' - 'prefix"}}}, "post": {"summary": "Create new contact information for an ' - 'identifier", "description": "Creates new information for an identifier, ' - 'overwriting all existing information for that identifier", "tags": ' - '["Contacts"], "parameters": [{"in": "path", "name": "prefix", "schema": ' - '{"type": "string"}, "required": true, "description": "qb64 identifier prefix ' - 'to add contact metadata to"}], "requestBody": {"required": true, "content": ' + 'challenge/response"}}}}, "/contacts/{prefix}": {"delete": {"summary": ' + '"Delete contact information associated with remote identfier", ' + '"description": "Delete contact information associated with remote ' + 'identfier", "tags": ["Contacts"], "parameters": [{"in": "path", "name": ' + '"prefix", "schema": {"type": "string"}, "required": true, "description": ' + '"qb64 identifier prefix of contact to delete"}], "responses": {"202": ' + '{"description": "Contact information successfully deleted for prefix"}, ' + '"404": {"description": "No contact information found for prefix"}}}, "get": ' + '{"summary": "Get contact information associated with single remote ' + 'identfier", "description": "Get contact information associated with single ' + 'remote identfier. All information is meta-data and kept in local storage ' + 'only", "tags": ["Contacts"], "parameters": [{"in": "path", "name": "prefix", ' + '"schema": {"type": "string"}, "required": true, "description": "qb64 ' + 'identifier prefix of contact to get"}], "responses": {"200": {"description": ' + '"Contact information successfully retrieved for prefix"}, "404": ' + '{"description": "No contact information found for prefix"}}}, "post": ' + '{"summary": "Create new contact information for an identifier", ' + '"description": "Creates new information for an identifier, overwriting all ' + 'existing information for that identifier", "tags": ["Contacts"], ' + '"parameters": [{"in": "path", "name": "prefix", "schema": {"type": ' + '"string"}, "required": true, "description": "qb64 identifier prefix to add ' + 'contact metadata to"}], "requestBody": {"required": true, "content": ' '{"application/json": {"schema": {"description": "Contact information", ' '"type": "object"}}}}, "responses": {"200": {"description": "Updated contact ' 'information for remote identifier"}, "400": {"description": "Invalid ' @@ -218,6 +208,29 @@ def test_spec_resource(helpers): '"binary"}}}}, "responses": {"200": {"description": "Image successfully ' 'uploaded"}}}}, "/oobi/{aid}/{role}": {"get": {}}, ' '"/identifiers/{name}/endroles/{role}": {"get": {}, "post": {}}, ' + '"/challenges/{name}/verify/{source}": {"post": {"summary": "Sign challange ' + 'message and forward to peer identfiier", "description": "Sign a challenge ' + 'word list received out of bands and send `exn` peer to peer message to ' + 'recipient", "tags": ["Challenge/Response"], "parameters": [{"in": "path", ' + '"name": "name", "schema": {"type": "string"}, "required": true, ' + '"description": "Human readable alias for the identifier to create"}], ' + '"requestBody": {"required": true, "content": {"application/json": {"schema": ' + '{"description": "Challenge response", "properties": {"recipient": {"type": ' + '"string", "description": "human readable alias recipient identifier to send ' + 'signed challenge to"}, "words": {"type": "array", "description": "challenge ' + 'in form of word list", "items": {"type": "string"}}}}}}}, "responses": ' + '{"202": {"description": "Success submission of signed ' + 'challenge/response"}}}, "put": {"summary": "Mark challenge response exn ' + 'message as signed", "description": "Mark challenge response exn message as ' + 'signed", "tags": ["Challenge/Response"], "parameters": [{"in": "path", ' + '"name": "name", "schema": {"type": "string"}, "required": true, ' + '"description": "Human readable alias for the identifier to create"}], ' + '"requestBody": {"required": true, "content": {"application/json": {"schema": ' + '{"description": "Challenge response", "properties": {"aid": {"type": ' + '"string", "description": "aid of signer of accepted challenge response"}, ' + '"said": {"type": "array", "description": "SAID of challenge message signed", ' + '"items": {"type": "string"}}}}}}}, "responses": {"202": {"description": ' + '"Success submission of signed challenge/response"}}}}, ' '"/oobi/{aid}/{role}/{eid}": {"get": {}}, ' '"/identifiers/{name}/endroles/{role}/{eid}": {"delete": {}}}, "info": ' '{"title": "KERIA Interactive Web Interface API", "version": "1.0.1"}, '