diff --git a/src/keria/app/agenting.py b/src/keria/app/agenting.py index 2d53d388..ac8294bc 100644 --- a/src/keria/app/agenting.py +++ b/src/keria/app/agenting.py @@ -49,7 +49,7 @@ from ..peer import exchanging as keriaexchanging from .specing import AgentSpecResource from ..core import authing, longrunning, httping -from ..core.authing import Authenticater +from ..core.authing import SignedHeaderAuthenticator from ..core.keeping import RemoteManager from ..db import basing @@ -152,8 +152,8 @@ def setupDoers(config: KERIAServerConfig): """ Sets up the HIO coroutines the KERIA agent server is composed of including three HTTP servers for a KERIA agent server: 1. Boot server for bootstrapping agents. Signify calls this with a signed inception event. - 2. Admin server for administrative tasks like creating agents. - 3. HTTP server for all other agent operations. + 2. Admin server for any Signify client related actions after bootstrapping. + 3. HTTP server for all other external agents send KERI events or messages for interactions. """ agency = Agency( name=config.name, @@ -173,7 +173,8 @@ def setupDoers(config: KERIAServerConfig): 'signature', 'signature-input', 'signify-resource', - 'signify-timestamp' + 'signify-timestamp', + 'signify-receiver' ] bootApp = falcon.App(middleware=falcon.CORSMiddleware( allow_origins='*', allow_credentials='*', @@ -188,14 +189,15 @@ def setupDoers(config: KERIAServerConfig): bootApp.add_route("/health", HealthEnd()) # Create Authenticater for verifying signatures on all requests - authn = Authenticater(agency=agency) + authn = SignedHeaderAuthenticator(agency=agency) - app = falcon.App(middleware=falcon.CORSMiddleware( - allow_origins='*', allow_credentials='*', - expose_headers=allowed_cors_headers)) + app = falcon.App( + middleware=falcon.CORSMiddleware(allow_origins='*', allow_credentials='*', expose_headers=allowed_cors_headers), + request_type=authing.ModifiableRequest + ) if config.cors: app.add_middleware(middleware=httping.HandleCORS()) - app.add_middleware(authing.SignatureValidationComponent(agency=agency, authn=authn, allowed=["/agent"])) + app.add_middleware(authing.AuthenticationMiddleware(agency=agency, authn=authn, allowed=["/agent"])) app.req_options.media_handlers.update(media.Handlers()) app.resp_options.media_handlers.update(media.Handlers()) diff --git a/src/keria/app/aiding.py b/src/keria/app/aiding.py index e2de25bc..9119d5b7 100644 --- a/src/keria/app/aiding.py +++ b/src/keria/app/aiding.py @@ -24,8 +24,8 @@ def loadEnds(app, agency, authn): - groupEnd = AgentResourceEnd(agency=agency, authn=authn) - app.add_route("/agent/{caid}", groupEnd) + agentEnd = AgentResourceEnd(agency=agency, authn=authn) + app.add_route("/agent/{caid}", agentEnd) aidsEnd = IdentifierCollectionEnd() app.add_route("/identifiers", aidsEnd) @@ -245,7 +245,8 @@ def on_put(self, req, rep, caid): ctrlHab = agent.hby.habByName(caid, ns="agent") ctrlHab.rotate(serder=rot, sigers=[core.Siger(qb64=sig) for sig in sigs]) - if not self.authn.verify(req): + # @TODO - foconnor: Not sure if this should be here - Signify is not signing headers for passcode rotation. + if not self.authn.inbound(req): raise falcon.HTTPForbidden(description="invalid signature on request") sxlt = body["sxlt"] diff --git a/src/keria/core/authing.py b/src/keria/core/authing.py index 78471f29..21b5ae6e 100644 --- a/src/keria/core/authing.py +++ b/src/keria/core/authing.py @@ -4,15 +4,78 @@ keria.core.authing module """ +import pysodium +import json +import sys +from urllib.parse import urlsplit +from io import BytesIO +from enum import Enum from urllib.parse import quote, unquote +from abc import ABC, abstractmethod import falcon from hio.help import Hict from keri import kering +from keri.core import coring, MtrDex from keri.end import ending from keri.help import helping +from typing import TYPE_CHECKING, Dict, Any +if TYPE_CHECKING: + from keria.app.agenting import Agency -class Authenticater: +CORS_HEADERS = [ + "access-control-allow-origin", + "access-control-allow-methods", + "access-control-allow-headers", + "access-control-expose-headers", + "access-control-max-age" +] + + +class AuthMode(Enum): + SIGNED_HEADERS = "SIGNED_HEADERS", + ESSR = "ESSR" + + +class ModifiableRequest(falcon.Request): + def reinit(self, env): + super().__init__(env) + + +class Authenticator(ABC): + def __init__(self, agency: 'Agency'): + """ Abstract agent authenticator for verifying requests and preparing responses + + Parameters: + agency(Agency): KERIA agency for handling creation and management of Signify agents + + Returns: + Authenticator + + """ + self.agency = agency + + @staticmethod + def getRequiredHeader(request: falcon.Request, header: str): + headers = request.headers + if header not in headers: + raise ValueError(f"Missing {header} header") + return headers[header] + + @staticmethod + def resource(request: falcon.Request): + return Authenticator.getRequiredHeader(request, "SIGNIFY-RESOURCE") + + @abstractmethod + def inbound(self, request: ModifiableRequest): + pass + + @abstractmethod + def outbound(self, request: ModifiableRequest, response: falcon.Response): + pass + + +class SignedHeaderAuthenticator(Authenticator): DefaultFields = ["Signify-Resource", "@method", @@ -20,42 +83,43 @@ class Authenticater: "Signify-Timestamp"] def __init__(self, agency): - """ Create Agent Authenticator for verifying requests and signing responses + """ Create agent authenticator based on RFC-9421 signed header message signatures Parameters: - agency(Agency): habitat of Agent for signing responses + agency(Agency): KERIA agency for handling creation and management of Signify agents Returns: - Authenicator: the configured habery + SignedHeaderAuthenticator """ - self.agency = agency + super().__init__(agency) - @staticmethod - def resource(request): - headers = request.headers - if "SIGNIFY-RESOURCE" not in headers: - raise ValueError("Missing signify resource header") + def inbound(self, request: ModifiableRequest): + """ Validate that the request is correctly signed based on our version of RFC-9421 - return headers["SIGNIFY-RESOURCE"] + Parameters: + request (ModifiableRequest): Falcon request object - def verify(self, request): + """ headers = request.headers - if "SIGNATURE-INPUT" not in headers or "SIGNATURE" not in headers: - return False - siginput = headers["SIGNATURE-INPUT"] - if not siginput: - return False - signature = headers["SIGNATURE"] - if not signature: - return False + siginput = self.getRequiredHeader(request, "SIGNATURE-INPUT") + signature = self.getRequiredHeader(request, "SIGNATURE") + + resource = self.resource(request) + agent = self.agency.get(resource) + + if agent is None: + raise kering.AuthNError("Unknown controller") + + if resource not in agent.agentHab.kevers: + raise kering.AuthNError("Unknown or invalid controller (controller KEL not resolved)") inputs = ending.desiginput(siginput.encode("utf-8")) inputs = [i for i in inputs if i.name == "signify"] if not inputs: - return False + raise kering.AuthNError("Missing signify inputs in signature") for inputage in inputs: items = [] @@ -92,120 +156,284 @@ def verify(self, request): items.append(f'"@signature-params: {params}"') ser = "\n".join(items).encode("utf-8") - resource = self.resource(request) - agent = self.agency.get(resource) - - if agent is None: - raise kering.AuthNError("unknown or invalid controller") - - if resource not in agent.agentHab.kevers: - raise kering.AuthNError("unknown or invalid controller") - ckever = agent.agentHab.kevers[resource] signages = ending.designature(signature) cig = signages[0].markers[inputage.name] if not ckever.verfers[0].verify(sig=cig.raw, ser=ser): raise kering.AuthNError(f"Signature for {inputage} invalid") - return True + request.path = unquote(request.path) + request.context.mode = AuthMode.SIGNED_HEADERS + request.context.agent = agent - def sign(self, agent, headers, method, path, fields=None): - """ Generate and add Signature Input and Signature fields to headers + def outbound(self, request: ModifiableRequest, response: falcon.Response): + """ Generate and add Signature Input and Signature fields to headers of the response Parameters: - agent (Agent): The agent that is replying to the request - headers (Hict): HTTP header to sign - method (str): HTTP method name of request/response - path (str): HTTP Query path of request/response - fields (Optional[list]): Optional list of Signature Input fields to sign. - - Returns: - headers (Hict): Modified headers with new Signature and Signature Input fields + request (ModifiableRequest): Falcon request object + response (Response): Falcon response object """ + request.path = quote(request.path) + agent = request.context.agent + response.set_header('Signify-Resource', agent.agentHab.pre) + response.set_header('Signify-Timestamp', helping.nowIso8601()) - if fields is None: - fields = self.DefaultFields - - header, qsig = ending.siginput("signify", method, path, headers, fields=fields, hab=agent.agentHab, + headers = Hict(response.headers) + header, qsig = ending.siginput("signify", request.method, request.path, headers, fields=self.DefaultFields, hab=agent.agentHab, alg="ed25519", keyid=agent.agentHab.pre) headers.extend(header) signage = ending.Signage(markers=dict(signify=qsig), indexed=False, signer=None, ordinal=None, digest=None, kind=None) headers.extend(ending.signature([signage])) - return headers + for key, val in headers.items(): + response.set_header(key, val) + +class ESSRAuthenticator(Authenticator): + def __init__(self, agency): + """ Create agent authenticator for verifying requests and signing+encrypting responses using KERI ESSR -class SignatureValidationComponent(object): - """ Validate Signature and Signature-Input header signatures """ + Parameters: + agency(Agency): KERIA agency for handling creation and management of Signify agents + + Returns: + ESSRAuthenticator - def __init__(self, agency, authn: Authenticater, allowed=None): """ + super().__init__(agency) + + def inbound(self, request: ModifiableRequest): + """ Validates that the wrapper request is correctly signed, and decrypts the embedded HTTP request which is + passed to the controllers. Parameters: - authn (Authenticater): Authenticator to validate signature headers on request + request (ModifiableRequest): Falcon request object + + """ + if request.path != "/": + raise kering.AuthNError("Request should not expose endpoint in the clear") + + signature = self.getRequiredHeader(request, "SIGNATURE") + dt = self.getRequiredHeader(request, "SIGNIFY-TIMESTAMP") + resource = self.resource(request) + receiver = self.getRequiredHeader(request, "SIGNIFY-RECEIVER") + + agent = self.agency.get(resource) + if agent is None or agent.pre != receiver: + raise kering.AuthNError("Unknown or invalid agent") + + if resource not in agent.agentHab.kevers: + raise kering.AuthNError("Unknown or invalid controller") + + ckever = agent.agentHab.kevers[resource] + signages = ending.designature(signature) + cig = signages[0].markers["signify"] + + cipher = request.bounded_stream.read() + payload = dict( + src=resource, + dest=agent.pre, + d=coring.Diger(ser=cipher, code=MtrDex.Blake3_256).qb64, + dt=dt, + ) + + if not ckever.verfers[0].verify(sig=cig.raw, ser=json.dumps(payload, separators=(",", ":")).encode("utf-8")): + raise kering.AuthNError("Signature invalid") + + # The real HTTP request is the plaintext of the body of the wrapper to POST / + environ = self.buildEnviron(agent.agentHab.decrypt(ser=cipher).decode("utf-8")) + + # ESSR "Encrypt Sender" + if "HTTP_SIGNIFY_RESOURCE" not in environ or environ["HTTP_SIGNIFY_RESOURCE"] != resource: + raise kering.AuthNError("ESSR payload missing or incorrect encrypted sender") + + request.reinit(environ) + request.path = unquote(request.path) + request.context.mode = AuthMode.ESSR + request.context.agent = agent + + def outbound(self, request: ModifiableRequest, response: falcon.Response): + """ Encrypt the HTTP response and place in the body of a wrapping request which contains the signature. + + Parameters: + request (ModifiableRequest): Falcon request object + response (Response): Falcon response object + + """ + request.path = quote(request.path) + agent = request.context.agent + response.set_header("SIGNIFY-RESOURCE", agent.agentHab.pre) # ESSR "Encrypt Sender" + inner = self.serializeResponse(request.env.get("SERVER_PROTOCOL"), response).encode("utf-8") + + response.status = 200 + + for header in response.headers.keys(): + if header.lower() in CORS_HEADERS: + continue + response.delete_header(header) + + dest = self.resource(request) + ckever = agent.agentHab.kevers[dest] + dt = helping.nowIso8601() + + response.set_header("SIGNIFY-RESOURCE", agent.agentHab.pre) + response.set_header("SIGNIFY-RECEIVER", dest) + response.set_header("SIGNIFY-TIMESTAMP", dt) + response.set_header("CONTENT-TYPE", "application/octet-stream") + + pubkey = pysodium.crypto_sign_pk_to_box_pk(ckever.verfers[0].raw) + raw = pysodium.crypto_box_seal(inner, pubkey) + + response.data = raw + response.text = None + + diger = coring.Diger(ser=raw, code=MtrDex.Blake3_256) + payload = dict( + src=agent.agentHab.pre, + dest=dest, + d=diger.qb64, + dt=dt, + ) + sig = agent.agentHab.sign(json.dumps(payload, separators=(",", ":")).encode("utf-8"), indexed=False) + signage = ending.Signage(markers=dict(signify=sig[0]), indexed=False, signer=None, ordinal=None, + digest=None, + kind=None) + for key, val in ending.signature([signage]).items(): + response.set_header(key, val) + + @staticmethod + def buildEnviron(raw: str) -> Dict[str, Any]: + """ Deserializes a HTTP request string into an environ dict that can initialize falcon request object + + Parameters: + raw (str): The serialized HTTP request + + Returns: + str: The serialized HTTP string + + """ + lines = raw.splitlines() + + method, url, protocol = lines[0].strip().split() + splitUrl = urlsplit(url) + splitHost = splitUrl.netloc.split(":") + + headers = {} + i = 1 + while i < len(lines) and lines[i].strip() != "": + header_line = lines[i].strip() + header_name, header_value = header_line.split(":", 1) + headers[header_name.strip()] = header_value.strip() + i += 1 + + body = "\n".join(lines[i + 1:]).strip().encode("utf-8") + + environ = { + "wsgi.input": BytesIO(body), + "wsgi.errors": sys.stderr, + "wsgi.url_scheme": splitUrl.scheme, + "REQUEST_METHOD": method, + "SERVER_NAME": splitHost[0], + "SERVER_PORT": splitHost[1] if len(splitHost) > 1 else ("433" if splitUrl.scheme == "https" else "80"), + "SERVER_PROTOCOL": protocol, + "PATH_INFO": splitUrl.path, + "QUERY_STRING": splitUrl.query, + "CONTENT_TYPE": headers.get("content-type", ""), + "CONTENT_LENGTH": str(len(body)) if body else "0", + } + + for key, value in headers.items(): + key = "HTTP_" + key.replace("-", "_").upper() + environ[key] = value + + return environ + + @staticmethod + def serializeResponse(protocol: str, response: falcon.Response) -> str: + """ Serializes a falcon response object into a HTTP string + + Parameters: + protocol (str): HTTP protocol string + response (falcon.Response): Falcon response object + + Returns: + str: The serialized HTTP string + + """ + status_line = f"{protocol} {response.status}" + headers = "\r\n".join([ + f"{key}: {value}" for key, value in response.headers.items() + if key.lower() not in CORS_HEADERS + ]) + + if response.text: + body = response.text + elif response.data: + body = response.data.decode("utf-8") + else: + body = "" + + return f"{status_line}\r\n{headers}\r\n\r\n{body}" + + +class AuthenticationMiddleware: + """ Authenticate incoming signed requests and sign outbound responses (optionally encrypted) """ + + def __init__(self, agency, authn: SignedHeaderAuthenticator, essrAuthn: ESSRAuthenticator = None, allowed=None): + """ + + Parameters: + agency(Agency): KERIA agency for handling creation and management of Signify agents + authn (SignedHeaderAuthenticator): Authenticator to validate signature headers on request + essrAuthn (ESSRAuthenticator): Authenticator based on KERI ESSR combination of signatures and encryption allowed (list[str]): Paths that are not protected. """ - if allowed is None: - allowed = [] self.agency = agency self.authn = authn - self.allowed = allowed + self.essrAuthn = essrAuthn if essrAuthn else ESSRAuthenticator(agency=agency) + self.allowed = allowed if allowed else [] - def process_request(self, req, resp): - """ Process request to ensure has a valid signature from caid + def process_request(self, req: ModifiableRequest, rep: falcon.Response): + """ Process request to ensure has a valid signature from caid, decrypting if necessary. Parameters: - req: Http request object - resp: Http response object + req (ModifiableRequest): Falcon request object + rep (Response): Falcon response object """ - for path in self.allowed: if req.path.startswith(path): return - req.path = quote(req.path) + authenticator = self.essrAuthn if req.path == "/" else self.authn try: - # Use Authenticater to verify the signature on the request - if self.authn.verify(req): - req.path = unquote(req.path) - resource = self.authn.resource(req) - agent = self.agency.get(caid=resource) - - req.context.agent = agent - return - - except kering.AuthNError: - pass - except ValueError: + authenticator.inbound(req) + return + except (kering.AuthNError, ValueError): pass - resp.complete = True # This short-circuits Falcon, skipping all further processing - resp.status = falcon.HTTP_401 + rep.complete = True # This short-circuits Falcon, skipping all further processing + rep.status = falcon.HTTP_401 return - def process_response(self, req, rep, resource, req_succeeded): - """ Process every falcon response by adding signature headers signed by the Agent AID. + def process_response(self, req: ModifiableRequest, rep: falcon.Response, _resource: object, _req_succeeded: bool): + """ Process every falcon response by signing the response with the Agent AID and encrypting if necessary. Parameters: - req (Request): Falcon request object + req (ModifiableRequest): Falcon request object rep (Response): Falcon response object - resource (End): endpoint object the request was routed to - req_succeeded (boot): True means the request was successfully handled + _resource (End): endpoint object the request was routed to + _req_succeeded (boot): True means the request was successfully handled """ + if not hasattr(req.context, "agent"): + return - if hasattr(req.context, "agent"): - req.path = quote(req.path) - agent = req.context.agent - rep.set_header('Signify-Resource', agent.agentHab.pre) - rep.set_header('Signify-Timestamp', helping.nowIso8601()) - headers = self.authn.sign(agent, Hict(rep.headers), req.method, req.path) - for key, val in headers.items(): - rep.set_header(key, val) + authenticator = self.essrAuthn if req.context.mode == AuthMode.ESSR else self.authn + authenticator.outbound(req, rep) diff --git a/src/keria/core/httping.py b/src/keria/core/httping.py index 763a86f1..ca9cd958 100644 --- a/src/keria/core/httping.py +++ b/src/keria/core/httping.py @@ -13,6 +13,7 @@ def process_request(self, req, resp): resp.set_header('Access-Control-Allow-Origin', '*') resp.set_header('Access-Control-Allow-Methods', '*') resp.set_header('Access-Control-Allow-Headers', '*') + resp.set_header('Access-Control-Expose-Headers', '*') resp.set_header('Access-Control-Max-Age', 1728000) # 20 days if req.method == 'OPTIONS': raise HTTPStatus(falcon.HTTP_200) diff --git a/tests/core/test_authing.py b/tests/core/test_authing.py index 7885f729..f69f56c8 100644 --- a/tests/core/test_authing.py +++ b/tests/core/test_authing.py @@ -5,6 +5,9 @@ Testing httping utils """ +import pysodium +from unittest import mock +import json import falcon import pytest from falcon import testing @@ -13,21 +16,25 @@ from keri import kering from keri import core from keri.app import habbing -from keri.core import parsing, eventing, coring +from keri.core import parsing, eventing, coring, MtrDex from keri.end import ending from keria.app import agenting from keria.core import authing -def test_authenticater(mockHelpingNowUTC): +def create_req(**kwargs): + return authing.ModifiableRequest(testing.create_environ(**kwargs)) + + +def test_signed_header_authenticator(mockHelpingNowUTC): salt = b'0123456789abcdef' salter = core.Salter(raw=salt) with habbing.openHab(name="caid", salt=salt, temp=True) as (controllerHby, controller): agency = agenting.Agency(name="agency", base='', bran=None, temp=True) - authn = authing.Authenticater(agency=agency) + authn = authing.SignedHeaderAuthenticator(agency=agency) # Initialize Hio so it will allow for the addition of an Agent hierarchy doist = doing.Doist(limit=1.0, tock=0.03125, real=True) @@ -62,10 +69,11 @@ def test_authenticater(mockHelpingNowUTC): 'Signify-Resource': 'EJPEPKslRHD_fkug3zmoyjQ90DazQAYWI8JIrV2QXyhg', 'Signify-Timestamp': '2022-09-24T00:05:48.196795+00:00'} - req = testing.create_req(method="POST", path="/boot", headers=dict(headers)) + req = create_req(method="POST", path="/boot", headers=dict(headers)) - with pytest.raises(kering.AuthNError): # Should fail if Agent hasn't resolved caid's KEL - authn.verify(req) + with pytest.raises(kering.AuthNError) as e: # Should fail if Agent hasn't resolved caid's KEL + authn.inbound(req) + assert str(e.value) == "Unknown or invalid controller (controller KEL not resolved)" agentKev = eventing.Kevery(db=agent.agentHab.db, lax=True, local=False) icp = controller.makeOwnInception() @@ -73,160 +81,478 @@ def test_authenticater(mockHelpingNowUTC): assert controller.pre in agent.agentHab.kevers - assert authn.verify(req) + # Malform Signature-Input + headers['Signature-Input'] = ('notsignify=("signify-resource" "@method" "@path" ' + '"signify-timestamp");created=1609459200;keyid' + '="EJPEPKslRHD_fkug3zmoyjQ90DazQAYWI8JIrV2QXyhg";alg="ed25519"') + + headers['Signature'] = ('indexed="?0";signify' + '="0BA9SX7Jyn66ZdCPOb0WqDEn1UC49GeSPypjVgeMrt6VLWKjEw9ij7Ndur7Wcrru_5eQNbSiNaiP4NQYWht5srEX"') + req = create_req(method="POST", path="/boot", headers=dict(headers)) + + with pytest.raises(kering.AuthNError) as e: + authn.inbound(req) + assert str(e.value) == "Missing signify inputs in signature" + + # Correct Signature-Input + headers['Signature-Input'] = ('signify=("signify-resource" "@method" "@path" ' + '"signify-timestamp");created=1609459200;keyid' + '="EJPEPKslRHD_fkug3zmoyjQ90DazQAYWI8JIrV2QXyhg";alg="ed25519"') + + # Bad signature + headers['Signature'] = ('indexed="?0";signify' + '="0BA9SX7Jyn66ZdCPOb0WqDEn1UC49GeSPypjVgeMrt6VLWKjEw9ij7Ndur7Wcrru_5eQNbSiNaiP4NQYWht5srEX"') + req = create_req(method="POST", path="/boot", headers=dict(headers)) + + with pytest.raises(kering.AuthNError) as e: + authn.inbound(req) + assert str(e.value) == ("Signature for Inputage(name='signify', fields=['signify-resource', '@method', " + "'@path', 'signify-timestamp'], created=1609459200, " + "keyid='EJPEPKslRHD_fkug3zmoyjQ90DazQAYWI8JIrV2QXyhg', alg='ed25519', expires=None, " + "nonce=None, context=None) invalid") + # Good signature + headers['Signature'] = ('indexed="?0";signify' + '="0BA9SX7Jyn66ZdCPOb0WqDEn1UC49GeSPypjVgeMrt6VLWKjEw9ij7Ndur7Wcrru_5eQNbSiNaiP4NQYWht5srEL"') + req = create_req(method="POST", path="/boot", headers=dict(headers)) + + authn.inbound(req) # Does not raise error - headers = Hict([ + rep = falcon.Response() + rep.set_headers([ ("Content-Type", "application/json"), ("Content-Length", "256"), ("Connection", "close"), - ("Signify-Resource", agent.agentHab.pre), - ("Signify-Timestamp", "2022-09-24T00:05:48.196795+00:00"), ]) - headers = authn.sign(agent, headers, method="POST", path="/oobis") - assert dict(headers) == {'Connection': 'close', - 'Content-Length': '256', - 'Content-Type': 'application/json', - 'Signature': 'indexed="?0";signify="0BBMtZXbDnueGAiNiNxHpZKrtBwdA7fPVz-dHiXOeSjkSu45C7' - 'EwCGgpAhnKX8Agz_yjJjAehyyz9Zc7ivtd_MkL"', - 'Signature-Input': 'signify=("signify-resource" "@method" "@path" ' - '"signify-timestamp");created=1609459200;keyid="EDqDrGuzned0HOKFTLq' - 'd7m7O7WGE5zYIOHrlCq4EnWxy";alg="ed25519"', - 'Signify-Resource': 'EDqDrGuzned0HOKFTLqd7m7O7WGE5zYIOHrlCq4EnWxy', - 'Signify-Timestamp': '2022-09-24T00:05:48.196795+00:00'} + authn.outbound(req, rep) + assert dict(rep.headers) == {'connection': 'close', + 'content-length': '256', + 'content-type': 'application/json', + 'signature': 'indexed="?0";signify="0BB3hErwyi9RPtlfPvVGrGW3HaU9GbuRse1Ip5b071L5gZ90jpdgzP0seEF4OttkDkrbYTeaZUMA3lIA1sQGdOEN"', + 'signature-input': 'signify=("signify-resource" "@method" "@path" ' + '"signify-timestamp");created=1609459200;keyid="EDqDrGuzned0HOKFTLqd7m7O7WGE5zYIOHrlCq4EnWxy";alg="ed25519"', + 'signify-resource': 'EDqDrGuzned0HOKFTLqd7m7O7WGE5zYIOHrlCq4EnWxy', + 'signify-timestamp': '2021-01-01T00:00:00.000000+00:00'} + req = create_req(method="POST", path="/boot", headers=dict(rep.headers)) + with pytest.raises(kering.AuthNError) as e: # Should because the agent won't be found + authn.inbound(req) + assert str(e.value) == "Unknown controller" - req = testing.create_req(method="POST", path="/boot", headers=dict(headers)) - with pytest.raises(kering.AuthNError): # Should because the agent won't be found - authn.verify(req) +def test_essr_authenticator(mockHelpingNowUTC): + salt = b'0123456789abcdef' + salter = core.Salter(raw=salt) -class MockAgency: - def __init__(self, agent=None): - self.agent = agent + with habbing.openHab(name="caid", salt=salt, temp=True) as (controllerHby, controller): + agency = agenting.Agency(name="agency", base='', bran=None, temp=True) + authn = authing.ESSRAuthenticator(agency=agency) - def get(self, caid=None): - return self.agent + # Initialize Hio so it will allow for the addition of an Agent hierarchy + doist = doing.Doist(limit=1.0, tock=0.03125, real=True) + doist.enter(doers=[agency]) + agent = agency.create(caid=controller.pre, salt=salter.qb64) + otherAgent = agency.create(caid="ELbpFmMh3eiK5rDj-_7L6e3Yk_CGxLVbhBopMh65gWXD") + + req = create_req(method="POST", path="/oobis") + with pytest.raises(kering.AuthNError) as e: + authn.inbound(req) + assert str(e.value) == "Request should not expose endpoint in the clear" + + dt = "2022-09-24T00:05:48.196795+00:00" + http = """GET http://127.0.0.1:3901/identifiers/aid1?x=y HTTP/1.1 +content-type: application/json +signify-resource: ECjmyrSFFfOb3VJi1JUKTy-Vn766h-VKl3XY8OEFdxBF + +""".encode("utf-8") + pubkey = pysodium.crypto_sign_pk_to_box_pk(agent.agentHab.kever.verfers[0].raw) + raw = pysodium.crypto_box_seal(http, pubkey) + + diger = coring.Diger(ser=raw, code=MtrDex.Blake3_256) + payload = dict( + src=controller.pre, + dest=agent.pre, + d=diger.qb64, + dt=dt, + ) + sig = controller.sign(json.dumps(payload, separators=(",", ":")).encode("utf-8"), indexed=False) + signature = \ + ending.signature([ending.Signage(markers=dict(signify=sig[0]), indexed=False, signer=None, ordinal=None, + digest=None, + kind=None)])['Signature'] + + req = create_req(method="POST", path="/", body=raw) + with pytest.raises(ValueError) as e: + authn.inbound(req) + assert str(e.value) == "Missing SIGNATURE header" + + req.headers["SIGNATURE"] = 'indexed="?0";signify="0BA9SX7Jyn66ZdCPOb0WqDEn1UC49GeSPypjVgeMrt6VLWKjEw9ij7Ndur7Wcrru_5eQNbSiNaiP4NQYWht5srEL' + with pytest.raises(ValueError) as e: + authn.inbound(req) + assert str(e.value) == "Missing SIGNIFY-TIMESTAMP header" + + req.headers["SIGNIFY-TIMESTAMP"] = dt + with pytest.raises(ValueError) as e: + authn.inbound(req) + assert str(e.value) == "Missing SIGNIFY-RESOURCE header" + + req.headers["SIGNIFY-RESOURCE"] = controller.pre + with pytest.raises(ValueError) as e: + authn.inbound(req) + assert str(e.value) == "Missing SIGNIFY-RECEIVER header" + + req.headers["SIGNIFY-RECEIVER"] = agent.pre + with pytest.raises(kering.AuthNError) as e: # Should fail if Agent hasn't resolved caid's KEL + authn.inbound(req) + assert str(e.value) == "Unknown or invalid controller" -class MockAuthN: - def __init__(self, valid=False, error=None): - self.valid = valid - self.error = error + agentKev = eventing.Kevery(db=agent.agentHab.db, lax=True, local=False) + icp = controller.makeOwnInception() + parsing.Parser().parse(ims=bytearray(icp), kvy=agentKev) + assert controller.pre in agent.agentHab.kevers - def verify(self, _): - if self.error is not None: - raise self.error + # After resolving, ensure fails for different receivers (existing but different and non-existing) + req.headers["SIGNIFY-RECEIVER"] = otherAgent.pre + with pytest.raises(kering.AuthNError) as e: + authn.inbound(req) + assert str(e.value) == "Unknown or invalid agent" + + req.headers["SIGNIFY-RECEIVER"] = "unknown-receiver" + with pytest.raises(kering.AuthNError) as e: + authn.inbound(req) + assert str(e.value) == "Unknown or invalid agent" + + # Back to correct + req.headers["SIGNIFY-RECEIVER"] = agent.pre + with pytest.raises(kering.AuthNError) as e: + authn.inbound(req) + assert str(e.value) == "Signature invalid" + + req = create_req(method="POST", path="/", body=raw, headers={ + "SIGNATURE": signature, + "SIGNIFY-TIMESTAMP": dt, + "SIGNIFY-RESOURCE": controller.pre, + "SIGNIFY-RECEIVER": agent.pre, + }) + with pytest.raises(kering.AuthNError) as e: + authn.inbound(req) + assert str(e.value) == "ESSR payload missing or incorrect encrypted sender" + + # Finally correct ESSR + dt = "2022-09-24T00:05:48.196795+00:00" + http = f"""GET http://127.0.0.1:3901/identifiers/aid1?x=y HTTP/1.1 + content-type: application/json + signify-resource: {controller.pre} + + """.encode("utf-8") + pubkey = pysodium.crypto_sign_pk_to_box_pk(agent.agentHab.kever.verfers[0].raw) + raw = pysodium.crypto_box_seal(http, pubkey) + + diger = coring.Diger(ser=raw, code=MtrDex.Blake3_256) + payload = dict( + src=controller.pre, + dest=agent.pre, + d=diger.qb64, + dt=dt, + ) + sig = controller.sign(json.dumps(payload, separators=(",", ":")).encode("utf-8"), indexed=False) + signature = \ + ending.signature([ending.Signage(markers=dict(signify=sig[0]), indexed=False, signer=None, ordinal=None, + digest=None, + kind=None)])['Signature'] + req = create_req(method="POST", path="/", body=raw, headers={ + "SIGNATURE": signature, + "SIGNIFY-TIMESTAMP": dt, + "SIGNIFY-RESOURCE": controller.pre, + "SIGNIFY-RECEIVER": agent.pre, + }) + + authn.inbound(req) + assert req.context.agent == agent + assert req.context.mode == authing.AuthMode.ESSR + assert req.get_header("Content-Type") == "application/json" + assert req.get_header("Signify-Resource") == controller.pre + assert req.path == "/identifiers/aid1" + assert req.get_param("x") == "y" + assert req.method == "GET" + + # Now test outbound + req = create_req(method="POST", path="/reward", headers={ + "SIGNIFY-RESOURCE": controller.pre, + "access-control-allow-origin": "*", + "access-control-allow-methods": "*", + "access-control-allow-headers": "*", + "access-control-max-age": "17200" + }) + req.context.agent = agent + req.context.mode = authing.AuthMode.ESSR - return self.valid + rep = falcon.Response() + rep.set_header("access-control-allow-origin", "*") + rep.set_header("access-control-allow-methods", "*") + rep.set_header("access-control-allow-headers", "*") + rep.set_header("access-control-max-age", 17200) + rep.status = "400 Bad Request" - @staticmethod - def resource(_): - return "" + authn.outbound(req, rep) + # Signature will change each time due to crypto_box_seal + assert rep.headers == {'signature': mock.ANY, + 'signify-resource': 'EDqDrGuzned0HOKFTLqd7m7O7WGE5zYIOHrlCq4EnWxy', + 'signify-receiver': 'EJPEPKslRHD_fkug3zmoyjQ90DazQAYWI8JIrV2QXyhg', + 'signify-timestamp': '2021-01-01T00:00:00.000000+00:00', + 'content-type': 'application/octet-stream', + 'access-control-allow-origin': '*', + 'access-control-allow-methods': '*', + 'access-control-allow-headers': '*', + 'access-control-max-age': '17200', + } + assert rep.status == 200 + + signages = ending.designature(rep.headers.get("signature")) + cig = signages[0].markers["signify"] + payload = dict( + src="EDqDrGuzned0HOKFTLqd7m7O7WGE5zYIOHrlCq4EnWxy", + dest="EJPEPKslRHD_fkug3zmoyjQ90DazQAYWI8JIrV2QXyhg", + d=coring.Diger(ser=rep.data, code=MtrDex.Blake3_256).qb64, + dt="2021-01-01T00:00:00.000000+00:00", + ) + assert agent.agentHab.kever.verfers[0].verify(sig=cig.raw, ser=json.dumps(payload, separators=(",", ":")).encode("utf-8")) + + plaintext = controller.decrypt(ser=rep.data).decode("utf-8") + assert plaintext == """HTTP/1.1 400 Bad Request\r +signify-resource: EDqDrGuzned0HOKFTLqd7m7O7WGE5zYIOHrlCq4EnWxy\r +\r +""" -def test_signature_validation(mockHelpingNowUTC): - vc = authing.SignatureValidationComponent(agency=MockAgency(), authn=MockAuthN(), allowed=["/test", "/reward"]) - req = testing.create_req(method="GET", path="/boot") +def test_build_environ(): + http = """GET http://127.0.0.1:3901/identifiers/aid1?x=y HTTP/1.1 + content-type: application/json + signify-resource: ECjmyrSFFfOb3VJi1JUKTy-Vn766h-VKl3XY8OEFdxBF + + """ + environ = authing.ESSRAuthenticator.buildEnviron(http) + assert environ == {'CONTENT_LENGTH': '0', + 'CONTENT_TYPE': 'application/json', + 'HTTP_CONTENT_TYPE': 'application/json', + 'HTTP_SIGNIFY_RESOURCE': 'ECjmyrSFFfOb3VJi1JUKTy-Vn766h-VKl3XY8OEFdxBF', + 'PATH_INFO': '/identifiers/aid1', + 'QUERY_STRING': 'x=y', + 'REQUEST_METHOD': 'GET', + 'SERVER_NAME': '127.0.0.1', + 'SERVER_PORT': '3901', + 'SERVER_PROTOCOL': 'HTTP/1.1', + 'wsgi.errors': mock.ANY, + 'wsgi.input': mock.ANY, + 'wsgi.url_scheme': 'http'} + + http = """POST http://127.0.0.1/ HTTP/1.0 + content-type: text/plain + signify-resource: ECjmyrSFFfOb3VJi1JUKTy-Vn766h-VKl3XY8OEFdxBF + + """ + environ = authing.ESSRAuthenticator.buildEnviron(http) + assert environ == {'CONTENT_LENGTH': '0', + 'CONTENT_TYPE': 'text/plain', + 'HTTP_CONTENT_TYPE': 'text/plain', + 'HTTP_SIGNIFY_RESOURCE': 'ECjmyrSFFfOb3VJi1JUKTy-Vn766h-VKl3XY8OEFdxBF', + 'PATH_INFO': '/', + 'QUERY_STRING': '', + 'REQUEST_METHOD': 'POST', + 'SERVER_NAME': '127.0.0.1', + 'SERVER_PORT': '80', + 'SERVER_PROTOCOL': 'HTTP/1.0', + 'wsgi.errors': mock.ANY, + 'wsgi.input': mock.ANY, + 'wsgi.url_scheme': 'http'} + + http = """POST https://127.0.0.1/main HTTP/1.1 + content-type: application/json + signify-resource: ECjmyrSFFfOb3VJi1JUKTy-Vn766h-VKl3XY8OEFdxBF + + {} + """ + environ = authing.ESSRAuthenticator.buildEnviron(http) + assert environ == {'CONTENT_LENGTH': '2', + 'CONTENT_TYPE': 'application/json', + 'HTTP_CONTENT_TYPE': 'application/json', + 'HTTP_SIGNIFY_RESOURCE': 'ECjmyrSFFfOb3VJi1JUKTy-Vn766h-VKl3XY8OEFdxBF', + 'PATH_INFO': '/main', + 'QUERY_STRING': '', + 'REQUEST_METHOD': 'POST', + 'SERVER_NAME': '127.0.0.1', + 'SERVER_PORT': '433', + 'SERVER_PROTOCOL': 'HTTP/1.1', + 'wsgi.errors': mock.ANY, + 'wsgi.input': mock.ANY, + 'wsgi.url_scheme': 'https'} + + http = """POST https://127.0.0.1/main HTTP/1.1 + content-type: application/json + signify-resource: ECjmyrSFFfOb3VJi1JUKTy-Vn766h-VKl3XY8OEFdxBF + + ññ + """ + environ = authing.ESSRAuthenticator.buildEnviron(http) + assert environ == {'CONTENT_LENGTH': '4', # ñ takes 2 + 'CONTENT_TYPE': 'application/json', + 'HTTP_CONTENT_TYPE': 'application/json', + 'HTTP_SIGNIFY_RESOURCE': 'ECjmyrSFFfOb3VJi1JUKTy-Vn766h-VKl3XY8OEFdxBF', + 'PATH_INFO': '/main', + 'QUERY_STRING': '', + 'REQUEST_METHOD': 'POST', + 'SERVER_NAME': '127.0.0.1', + 'SERVER_PORT': '433', + 'SERVER_PROTOCOL': 'HTTP/1.1', + 'wsgi.errors': mock.ANY, + 'wsgi.input': mock.ANY, + 'wsgi.url_scheme': 'https'} + + +def test_serialize_response(): rep = falcon.Response() + rep.set_headers([ + ("signify-resource", "EDqDrGuzned0HOKFTLqd7m7O7WGE5zYIOHrlCq4EnWxy"), + ("access-control-allow-origin", "*"), # CORS should be ignored + ("access-control-allow-methods", "*"), + ("access-control-allow-headers", "*"), + ("access-control-expose-headers", "*"), + ("access-control-max-age", "1728000") + ]) + rep.status = "400 Bad Request" + + serialized = authing.ESSRAuthenticator.serializeResponse("HTTP/1.1", rep) + assert serialized == """HTTP/1.1 400 Bad Request\r +signify-resource: EDqDrGuzned0HOKFTLqd7m7O7WGE5zYIOHrlCq4EnWxy\r +\r +""" - vc.process_request(req, rep) - assert rep.complete is True - assert rep.status == falcon.HTTP_401 + rep.data = json.dumps({"a": "b"}).encode("utf-8") + serialized = authing.ESSRAuthenticator.serializeResponse("HTTP/1.1", rep) + assert serialized == """HTTP/1.1 400 Bad Request\r +signify-resource: EDqDrGuzned0HOKFTLqd7m7O7WGE5zYIOHrlCq4EnWxy\r +\r +{"a": "b"}""" - req = testing.create_req(method="POST", path="/boot") - rep = falcon.Response() + rep.data = None + rep.text = "Identifier not found!" + serialized = authing.ESSRAuthenticator.serializeResponse("HTTP/1.1", rep) + assert serialized == """HTTP/1.1 400 Bad Request\r +signify-resource: EDqDrGuzned0HOKFTLqd7m7O7WGE5zYIOHrlCq4EnWxy\r +\r +Identifier not found!""" - vc.process_request(req, rep) - assert rep.complete is True - assert rep.status == falcon.HTTP_401 - req = testing.create_req(method="POST", path="/test") +class MockAgency: + def __init__(self, agent=None): + self.agent = agent + + def get(self, caid=None): + return self.agent + + +def test_authentication_middleware(mockHelpingNowUTC): + mockAuthN = mock.Mock(name="MockAuthN") + mockESSRAuthN = mock.Mock(name="MockESSRAuthN") + + agent = object() + vc = authing.AuthenticationMiddleware(agency=MockAgency(agent=agent), authn=mockAuthN, essrAuthn=mockESSRAuthN, + allowed=["/test", "/reward"]) + + req = create_req(method="POST", path="/test") rep = falcon.Response() vc.process_request(req, rep) assert rep.complete is False assert rep.status == falcon.HTTP_200 - req = testing.create_req(method="POST", path="/reward") + req = create_req(method="POST", path="/reward") rep = falcon.Response() vc.process_request(req, rep) assert rep.complete is False assert rep.status == falcon.HTTP_200 - agent = object() - vc = authing.SignatureValidationComponent(agency=MockAgency(agent=agent), authn=MockAuthN(valid=True), - allowed=["/test", "/reward"]) - - req = testing.create_req(method="POST", path="/test") + req = create_req(method="GET", path="/identifiers") rep = falcon.Response() vc.process_request(req, rep) assert rep.complete is False assert rep.status == falcon.HTTP_200 - req = testing.create_req(method="POST", path="/reward") + req = create_req(method="POST", path="/identifiers") rep = falcon.Response() vc.process_request(req, rep) + assert mockAuthN.inbound.call_count == 2 # not 4 assert rep.complete is False assert rep.status == falcon.HTTP_200 - req = testing.create_req(method="GET", path="/identifiers") + mockAuthN.reset_mock() + mockAuthN.inbound.side_effect = kering.AuthNError() + + req = create_req(method="POST", path="/identifiers") rep = falcon.Response() vc.process_request(req, rep) - assert rep.complete is False - assert rep.status == falcon.HTTP_200 + mockAuthN.inbound.assert_called_once() + assert rep.complete is True + assert rep.status == falcon.HTTP_401 + + mockAuthN.reset_mock() + mockAuthN.inbound.side_effect = ValueError() - req = testing.create_req(method="POST", path="/identifiers") + req = create_req(method="POST", path="/identifiers") rep = falcon.Response() vc.process_request(req, rep) + mockAuthN.inbound.assert_called_once() + assert rep.complete is True + assert rep.status == falcon.HTTP_401 + + req = create_req(method="POST", path="/") + rep = falcon.Response() + + vc.process_request(req, rep) + mockESSRAuthN.inbound.assert_called_once() assert rep.complete is False assert rep.status == falcon.HTTP_200 - vc = authing.SignatureValidationComponent(agency=MockAgency(), authn=MockAuthN(error=kering.AuthNError())) - req = testing.create_req(method="POST", path="/identifiers") + mockESSRAuthN.reset_mock() + mockESSRAuthN.inbound.side_effect = kering.AuthNError() + + req = create_req(method="POST", path="/") rep = falcon.Response() vc.process_request(req, rep) + mockESSRAuthN.inbound.assert_called_once() assert rep.complete is True assert rep.status == falcon.HTTP_401 - vc = authing.SignatureValidationComponent(agency=MockAgency(), authn=MockAuthN(error=ValueError())) - req = testing.create_req(method="POST", path="/identifiers") + mockESSRAuthN.reset_mock() + mockESSRAuthN.inbound.side_effect = ValueError() + + req = create_req(method="POST", path="/") rep = falcon.Response() vc.process_request(req, rep) + mockESSRAuthN.inbound.assert_called_once() assert rep.complete is True assert rep.status == falcon.HTTP_401 - salt = b'0123456789abcdef' - salter = core.Salter(raw=salt) - with habbing.openHab(name="caid", salt=salt, temp=True) as (controllerHby, controller): - - agency = agenting.Agency(name="agency", base='', bran=None, temp=True) - authn = authing.Authenticater(agency=agency) + # Now test outbound + req = create_req(method="POST", path="/identifiers") + rep = falcon.Response() - # Initialize Hio so it will allow for the addition of an Agent hierarchy - doist = doing.Doist(limit=1.0, tock=0.03125, real=True) - doist.enter(doers=[agency]) + req.context.agent = agent + req.context.mode = authing.AuthMode.SIGNED_HEADERS - agent = agency.create(caid=controller.pre, salt=salter.qb64) - req = testing.create_req(method="POST", path="/reward") - req.context.agent = agent - rep = falcon.Response() + vc.process_response(req, rep, None, True) + mockAuthN.outbound.assert_called_once() - vc = authing.SignatureValidationComponent(agency=agency, authn=authn) - vc.process_response(req, rep, None, True) - assert rep.headers == {'signature': 'indexed="?0";signify="0BA6rPilZo3Fv3e3lIRxoZncbrn0RpPdDcQqxN3Jolsvl4WBkpXl' - 'rmFJ5NmLbQNikChIzUiDn1XEMU-ecCTnSmYD"', - 'signature-input': 'signify=("signify-resource" "@method" "@path" ' - '"signify-timestamp");created=1609459200;keyid="EDqDrGuzned0HOKFTLqd7' - 'm7O7WGE5zYIOHrlCq4EnWxy";alg="ed25519"', - 'signify-resource': 'EDqDrGuzned0HOKFTLqd7m7O7WGE5zYIOHrlCq4EnWxy', - 'signify-timestamp': '2021-01-01T00:00:00.000000+00:00'} + req.context.mode = authing.AuthMode.ESSR + vc.process_response(req, rep, None, True) + mockESSRAuthN.outbound.assert_called_once()