diff --git a/src/keria/app/agenting.py b/src/keria/app/agenting.py index 66fb097..7dc4d78 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 Authenticator +from ..core.authing import SignedHeaderAuthenticator from ..core.keeping import RemoteManager from ..db import basing @@ -197,7 +197,7 @@ def setupDoers(config: KERIAServerConfig): bootApp.add_route("/health", HealthEnd()) # Create Authenticater for verifying signatures on all requests - authn = Authenticator(agency=agency) + authn = SignedHeaderAuthenticator(agency=agency) app = falcon.App( middleware=falcon.CORSMiddleware(allow_origins='*', allow_credentials='*', expose_headers=allowed_cors_headers), @@ -205,7 +205,7 @@ def setupDoers(config: KERIAServerConfig): ) 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 54ea424..9119d5b 100644 --- a/src/keria/app/aiding.py +++ b/src/keria/app/aiding.py @@ -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 dc93246..21b5ae6 100644 --- a/src/keria/core/authing.py +++ b/src/keria/core/authing.py @@ -9,12 +9,20 @@ 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 + CORS_HEADERS = [ "access-control-allow-origin", "access-control-allow-methods", @@ -24,36 +32,186 @@ ] +class AuthMode(Enum): + SIGNED_HEADERS = "SIGNED_HEADERS", + ESSR = "ESSR" + + class ModifiableRequest(falcon.Request): - def replace(self, env): + def reinit(self, env): super().__init__(env) -class Authenticator: - def __init__(self, agency): - """ Create Agent Authenticator for verifying requests and signing responses using ESSR +class Authenticator(ABC): + def __init__(self, agency: 'Agency'): + """ Abstract agent authenticator for verifying requests and preparing responses Parameters: - agency(Agency): habitat of Agent for signing responses + agency(Agency): KERIA agency for handling creation and management of Signify agents Returns: - Authenticator: the configured authenticator + Authenticator """ self.agency = agency @staticmethod - def getRequiredHeader(request, header): + 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): + def resource(request: falcon.Request): return Authenticator.getRequiredHeader(request, "SIGNIFY-RESOURCE") - def unwrap(self, request): + @abstractmethod + def inbound(self, request: ModifiableRequest): + pass + + @abstractmethod + def outbound(self, request: ModifiableRequest, response: falcon.Response): + pass + + +class SignedHeaderAuthenticator(Authenticator): + + DefaultFields = ["Signify-Resource", + "@method", + "@path", + "Signify-Timestamp"] + + def __init__(self, agency): + """ Create agent authenticator based on RFC-9421 signed header message signatures + + Parameters: + agency(Agency): KERIA agency for handling creation and management of Signify agents + + Returns: + SignedHeaderAuthenticator + + """ + super().__init__(agency) + + def inbound(self, request: ModifiableRequest): + """ Validate that the request is correctly signed based on our version of RFC-9421 + + Parameters: + request (ModifiableRequest): Falcon request object + + """ + headers = request.headers + + 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: + raise kering.AuthNError("Missing signify inputs in signature") + + for inputage in inputs: + items = [] + for field in inputage.fields: + if field.startswith("@"): + if field == "@method": + items.append(f'"{field}": {request.method}') + elif field == "@path": + items.append(f'"{field}": {request.path}') + + else: + key = field.upper() + field = field.lower() + if key not in headers: + continue + + value = ending.normalize(headers[key]) + items.append(f'"{field}": {value}') + + values = [f"({' '.join(inputage.fields)})", f"created={inputage.created}"] + if inputage.expires is not None: + values.append(f"expires={inputage.expires}") + if inputage.nonce is not None: + values.append(f"nonce={inputage.nonce}") + if inputage.keyid is not None: + values.append(f"keyid={inputage.keyid}") + if inputage.context is not None: + values.append(f"context={inputage.context}") + if inputage.alg is not None: + values.append(f"alg={inputage.alg}") + + params = ';'.join(values) + + items.append(f'"@signature-params: {params}"') + ser = "\n".join(items).encode("utf-8") + + 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") + + request.path = unquote(request.path) + request.context.mode = AuthMode.SIGNED_HEADERS + request.context.agent = agent + + def outbound(self, request: ModifiableRequest, response: falcon.Response): + """ Generate and add Signature Input and Signature fields to headers of the response + + 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) + response.set_header('Signify-Timestamp', helping.nowIso8601()) + + 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])) + + 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 + + Parameters: + agency(Agency): KERIA agency for handling creation and management of Signify agents + + Returns: + ESSRAuthenticator + + """ + 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: + request (ModifiableRequest): Falcon request object + + """ if request.path != "/": raise kering.AuthNError("Request should not expose endpoint in the clear") @@ -84,42 +242,52 @@ def unwrap(self, request): if not ckever.verfers[0].verify(sig=cig.raw, ser=json.dumps(payload, separators=(",", ":")).encode("utf-8")): raise kering.AuthNError("Signature invalid") - plaintext = agent.agentHab.decrypt(ser=cipher).decode("utf-8") - environ = buildEnviron(plaintext) + # 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") - return agent, environ + request.reinit(environ) + request.path = unquote(request.path) + request.context.mode = AuthMode.ESSR + request.context.agent = agent - @staticmethod - def wrap(req, rep): - agent = req.context.agent - rep.set_header("SIGNIFY-RESOURCE", agent.agentHab.pre) # ESSR "Encrypt Sender" - inner = serializeResponse(req.env.get("SERVER_PROTOCOL"), rep).encode("utf-8") + 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") - rep.status = 200 + response.status = 200 - for header in rep.headers.keys(): + for header in response.headers.keys(): if header.lower() in CORS_HEADERS: continue - rep.delete_header(header) + response.delete_header(header) - dest = Authenticator.resource(req) + dest = self.resource(request) ckever = agent.agentHab.kevers[dest] dt = helping.nowIso8601() - rep.set_header("SIGNIFY-RESOURCE", agent.agentHab.pre) - rep.set_header("SIGNIFY-RECEIVER", dest) - rep.set_header("SIGNIFY-TIMESTAMP", dt) - rep.set_header("CONTENT-TYPE", "application/octet-stream") + 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) - rep.data = raw - rep.text = None + response.data = raw + response.text = None diger = coring.Diger(ser=raw, code=MtrDex.Blake3_256) payload = dict( @@ -133,115 +301,139 @@ def wrap(req, rep): digest=None, kind=None) for key, val in ending.signature([signage]).items(): - rep.set_header(key, val) + 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 -class SignatureValidationComponent(object): - """ Validate Signature and Signature-Input header signatures """ + Parameters: + raw (str): The serialized HTTP request + + Returns: + str: The serialized HTTP string - def __init__(self, agency, authn: Authenticator, allowed=None): """ + 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: - authn (Authenticater): Authenticator to validate signature headers on request + 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: ModifiableRequest, resp: falcon.Response): - """ Process request to ensure has a valid ESSR payload 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 (ModifiableRequest): Falcon request object - resp (Response): Falcon response object + rep (Response): Falcon response object + """ for path in self.allowed: if req.path.startswith(path): return + authenticator = self.essrAuthn if req.path == "/" else self.authn + try: - # Decrypt embedded HTTP req and inject into Falcon req - agent, environ = self.authn.unwrap(req) - req.replace(environ) - req.context.agent = agent + 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: ModifiableRequest, rep: falcon.Response, _resource: object, _req_succeeded: bool): - """ Process every falcon response by adding signature headers signed by the Agent AID. + """ Process every falcon response by signing the response with the Agent AID and encrypting if necessary. Parameters: req (ModifiableRequest): Falcon request object rep (Response): Falcon response object _resource (End): endpoint object the request was routed to - _req_succeeded (bool): True means the request was successfully handled + _req_succeeded (boot): True means the request was successfully handled """ - if hasattr(req.context, "agent"): - self.authn.wrap(req, rep) - - -def buildEnviron(raw: str): - 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() - - environ = { - "wsgi.input": BytesIO(body.encode("utf-8")), - "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 - - -def serializeResponse(protocol: str, response: falcon.Response): - 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}" + if not hasattr(req.context, "agent"): + return + + authenticator = self.essrAuthn if req.context.mode == AuthMode.ESSR else self.authn + authenticator.outbound(req, rep) + diff --git a/tests/core/test_authing.py b/tests/core/test_authing.py index 11f2d4e..f69f56c 100644 --- a/tests/core/test_authing.py +++ b/tests/core/test_authing.py @@ -12,6 +12,7 @@ import pytest from falcon import testing from hio.base import doing +from hio.help import Hict from keri import kering from keri import core from keri.app import habbing @@ -22,13 +23,131 @@ from keria.core import authing -def test_authenticater_unwrap(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.Authenticator(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) + doist.enter(doers=[agency]) + + agent = agency.create(caid=controller.pre, salt=salter.qb64) + + # Create authenticater with Agent and controllers AID + headers = Hict([ + ("Content-Type", "application/json"), + ("Content-Length", "256"), + ("Connection", "close"), + ("Signify-Resource", controller.pre), + ("Signify-Timestamp", "2022-09-24T00:05:48.196795+00:00"), + ]) + + header, qsig = ending.siginput("signify", "POST", "/boot", headers, fields=authn.DefaultFields, + hab=controller, alg="ed25519", keyid=controller.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])) + + assert dict(headers) == {'Connection': 'close', + 'Content-Length': '256', + 'Content-Type': 'application/json', + 'Signature': 'indexed="?0";signify="0BA9SX7Jyn66ZdCPOb0WqDEn1UC49GeSPypjVgeMrt6VLWKjEw' + '9ij7Ndur7Wcrru_5eQNbSiNaiP4NQYWht5srEL"', + 'Signature-Input': 'signify=("signify-resource" "@method" "@path" ' + '"signify-timestamp");created=1609459200;keyid="EJPEPKslRHD_fkug3zm' + 'oyjQ90DazQAYWI8JIrV2QXyhg";alg="ed25519"', + 'Signify-Resource': 'EJPEPKslRHD_fkug3zmoyjQ90DazQAYWI8JIrV2QXyhg', + 'Signify-Timestamp': '2022-09-24T00:05:48.196795+00:00'} + + req = create_req(method="POST", path="/boot", headers=dict(headers)) + + 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() + parsing.Parser().parse(ims=bytearray(icp), kvy=agentKev) + + assert controller.pre in agent.agentHab.kevers + + # 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 + + rep = falcon.Response() + rep.set_headers([ + ("Content-Type", "application/json"), + ("Content-Length", "256"), + ("Connection", "close"), + ]) + + 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" + + +def test_essr_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.ESSRAuthenticator(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) @@ -37,9 +156,9 @@ def test_authenticater_unwrap(mockHelpingNowUTC): agent = agency.create(caid=controller.pre, salt=salter.qb64) otherAgent = agency.create(caid="ELbpFmMh3eiK5rDj-_7L6e3Yk_CGxLVbhBopMh65gWXD") - req = testing.create_req(method="POST", path="/oobis") + req = create_req(method="POST", path="/oobis") with pytest.raises(kering.AuthNError) as e: - authn.unwrap(req) + authn.inbound(req) assert str(e.value) == "Request should not expose endpoint in the clear" dt = "2022-09-24T00:05:48.196795+00:00" @@ -60,33 +179,33 @@ def test_authenticater_unwrap(mockHelpingNowUTC): ) 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'] + ending.signature([ending.Signage(markers=dict(signify=sig[0]), indexed=False, signer=None, ordinal=None, + digest=None, + kind=None)])['Signature'] - req = testing.create_req(method="POST", path="/", body=raw) + req = create_req(method="POST", path="/", body=raw) with pytest.raises(ValueError) as e: - authn.unwrap(req) + 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.unwrap(req) + authn.inbound(req) assert str(e.value) == "Missing SIGNIFY-TIMESTAMP header" req.headers["SIGNIFY-TIMESTAMP"] = dt with pytest.raises(ValueError) as e: - authn.unwrap(req) + authn.inbound(req) assert str(e.value) == "Missing SIGNIFY-RESOURCE header" req.headers["SIGNIFY-RESOURCE"] = controller.pre with pytest.raises(ValueError) as e: - authn.unwrap(req) + 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.unwrap(req) + authn.inbound(req) assert str(e.value) == "Unknown or invalid controller" agentKev = eventing.Kevery(db=agent.agentHab.db, lax=True, local=False) @@ -97,28 +216,28 @@ def test_authenticater_unwrap(mockHelpingNowUTC): # 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.unwrap(req) + 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.unwrap(req) + 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.unwrap(req) + authn.inbound(req) assert str(e.value) == "Signature invalid" - req = testing.create_req(method="POST", path="/", body=raw, headers={ + 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.unwrap(req) + authn.inbound(req) assert str(e.value) == "ESSR payload missing or incorrect encrypted sender" # Finally correct ESSR @@ -143,122 +262,23 @@ def test_authenticater_unwrap(mockHelpingNowUTC): ending.signature([ending.Signage(markers=dict(signify=sig[0]), indexed=False, signer=None, ordinal=None, digest=None, kind=None)])['Signature'] - req = testing.create_req(method="POST", path="/", body=raw, headers={ + req = create_req(method="POST", path="/", body=raw, headers={ "SIGNATURE": signature, "SIGNIFY-TIMESTAMP": dt, "SIGNIFY-RESOURCE": controller.pre, "SIGNIFY-RECEIVER": agent.pre, }) - agentFound, environ = authn.unwrap(req) - assert agentFound == agent - assert environ["HTTP_CONTENT_TYPE"] == "application/json" - assert environ["HTTP_SIGNIFY_RESOURCE"] == controller.pre - assert environ["PATH_INFO"] == "/identifiers/aid1" - assert environ["QUERY_STRING"] == "x=y" - assert environ["REQUEST_METHOD"] == "GET" - - -class MockAgency: - def __init__(self, agent=None): - self.agent = agent - - def get(self): - return self.agent - - -class MockAuthN: - def __init__(self, agent, environ, error=None): - self.agent = agent - self.environ = environ - self.error = error - - def unwrap(self, _): - if self.error is not None: - raise self.error - - return self.agent, self.environ - - @staticmethod - def resource(_): - return "" - - -def create_req(**kwargs): - return authing.ModifiableRequest(testing.create_environ(**kwargs)) - - -def test_signature_validation(mockHelpingNowUTC): - agent = object() - environ = authing.buildEnviron("""POST http://127.0.0.1:3901/main HTTP/1.1 -content-type: application/octet-stream -signify-resource: ECjmyrSFFfOb3VJi1JUKTy-Vn766h-VKl3XY8OEFdxBF - -""") - vc = authing.SignatureValidationComponent(agency=MockAgency(agent=agent), authn=MockAuthN(agent=agent, environ=environ), - 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 = 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 - - 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 = 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 - - vc = authing.SignatureValidationComponent(agency=MockAgency(), authn=MockAuthN(agent=agent, environ=environ, error=kering.AuthNError())) - req = testing.create_req(method="POST", path="/identifiers") - rep = falcon.Response() - - vc.process_request(req, rep) - assert rep.complete is True - assert rep.status == falcon.HTTP_401 - - vc = authing.SignatureValidationComponent(agency=MockAgency(), authn=MockAuthN(agent=agent, environ=environ, error=ValueError())) - req = testing.create_req(method="POST", path="/identifiers") - rep = falcon.Response() - - vc.process_request(req, rep) - 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.Authenticator(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) - doist.enter(doers=[agency]) - - agent = agency.create(caid=controller.pre, salt=salter.qb64) - 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 + 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": "*", @@ -267,6 +287,7 @@ def test_signature_validation(mockHelpingNowUTC): "access-control-max-age": "17200" }) req.context.agent = agent + req.context.mode = authing.AuthMode.ESSR rep = falcon.Response() rep.set_header("access-control-allow-origin", "*") @@ -275,8 +296,7 @@ def test_signature_validation(mockHelpingNowUTC): rep.set_header("access-control-max-age", 17200) rep.status = "400 Bad Request" - vc = authing.SignatureValidationComponent(agency=agency, authn=authn) - vc.process_response(req, rep, None, True) + authn.outbound(req, rep) # Signature will change each time due to crypto_box_seal assert rep.headers == {'signature': mock.ANY, @@ -314,7 +334,7 @@ def test_build_environ(): signify-resource: ECjmyrSFFfOb3VJi1JUKTy-Vn766h-VKl3XY8OEFdxBF """ - environ = authing.buildEnviron(http) + environ = authing.ESSRAuthenticator.buildEnviron(http) assert environ == {'CONTENT_LENGTH': '0', 'CONTENT_TYPE': 'application/json', 'HTTP_CONTENT_TYPE': 'application/json', @@ -330,11 +350,11 @@ def test_build_environ(): 'wsgi.url_scheme': 'http'} http = """POST http://127.0.0.1/ HTTP/1.0 - content-type: text/plain + content-type: text/plain signify-resource: ECjmyrSFFfOb3VJi1JUKTy-Vn766h-VKl3XY8OEFdxBF """ - environ = authing.buildEnviron(http) + environ = authing.ESSRAuthenticator.buildEnviron(http) assert environ == {'CONTENT_LENGTH': '0', 'CONTENT_TYPE': 'text/plain', 'HTTP_CONTENT_TYPE': 'text/plain', @@ -355,7 +375,7 @@ def test_build_environ(): {} """ - environ = authing.buildEnviron(http) + environ = authing.ESSRAuthenticator.buildEnviron(http) assert environ == {'CONTENT_LENGTH': '2', 'CONTENT_TYPE': 'application/json', 'HTTP_CONTENT_TYPE': 'application/json', @@ -369,3 +389,170 @@ def test_build_environ(): '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 +""" + + 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"}""" + + 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!""" + + +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 = 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 + + 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 = 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 + + mockAuthN.reset_mock() + mockAuthN.inbound.side_effect = kering.AuthNError() + + 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 + + mockAuthN.reset_mock() + mockAuthN.inbound.side_effect = ValueError() + + 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 + + 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 + + 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 + + # Now test outbound + req = create_req(method="POST", path="/identifiers") + rep = falcon.Response() + + req.context.agent = agent + req.context.mode = authing.AuthMode.SIGNED_HEADERS + + vc.process_response(req, rep, None, True) + mockAuthN.outbound.assert_called_once() + + req.context.mode = authing.AuthMode.ESSR + vc.process_response(req, rep, None, True) + mockESSRAuthN.outbound.assert_called_once()