From f7cfb9d16dd92c06168f1f6f4a83270e1924b400 Mon Sep 17 00:00:00 2001 From: iFergal Date: Wed, 15 Jan 2025 19:51:31 -0300 Subject: [PATCH 1/9] feat: ESSR client authentication --- src/keria/app/agenting.py | 15 +- src/keria/app/aiding.py | 4 +- src/keria/core/authing.py | 300 ++++++++++++++++-------------- tests/core/test_authing.py | 367 +++++++++++++++++++++++++------------ 4 files changed, 426 insertions(+), 260 deletions(-) diff --git a/src/keria/app/agenting.py b/src/keria/app/agenting.py index 59895026..d8dd1a2a 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 Authenticator 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, @@ -188,11 +188,12 @@ def setupDoers(config: KERIAServerConfig): bootApp.add_route("/health", HealthEnd()) # Create Authenticater for verifying signatures on all requests - authn = Authenticater(agency=agency) + authn = Authenticator(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"])) diff --git a/src/keria/app/aiding.py b/src/keria/app/aiding.py index 6fea1041..72428ad5 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) diff --git a/src/keria/core/authing.py b/src/keria/core/authing.py index 78471f29..bce5e6ae 100644 --- a/src/keria/core/authing.py +++ b/src/keria/core/authing.py @@ -4,143 +4,139 @@ keria.core.authing module """ -from urllib.parse import quote, unquote +import pysodium +import json +import sys +from urllib.parse import urlsplit +from io import BytesIO 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 +CORS_HEADERS = [ + "access-control-allow-origin", + "access-control-allow-methods", + "access-control-allow-headers", + "access-control-max-age" +] -class Authenticater: - DefaultFields = ["Signify-Resource", - "@method", - "@path", - "Signify-Timestamp"] +class ModifiableRequest(falcon.Request): + def replace(self, env): + super().__init__(env) + +class Authenticator: def __init__(self, agency): - """ Create Agent Authenticator for verifying requests and signing responses + """ Create Agent Authenticator for verifying requests and signing responses using ESSR Parameters: agency(Agency): habitat of Agent for signing responses Returns: - Authenicator: the configured habery + Authenticator: the configured authenticator """ self.agency = agency @staticmethod - def resource(request): + def getRequiredHeader(request, header): headers = request.headers - if "SIGNIFY-RESOURCE" not in headers: - raise ValueError("Missing signify resource header") + if header not in headers: + raise ValueError(f"Missing {header} header") + return headers[header] - return headers["SIGNIFY-RESOURCE"] + @staticmethod + def resource(request): + return Authenticator.getRequiredHeader(request, "SIGNIFY-RESOURCE") - 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 - - inputs = ending.desiginput(siginput.encode("utf-8")) - inputs = [i for i in inputs if i.name == "signify"] - - if not inputs: - return False - - 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") - - 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 - - def sign(self, agent, headers, method, path, fields=None): - """ Generate and add Signature Input and Signature fields to headers + def unwrap(self, request): + if request.path != "/": + raise kering.AuthNError("Request should not expose endpoint in the clear") - 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. + signature = self.getRequiredHeader(request, "SIGNATURE") + dt = self.getRequiredHeader(request, "SIGNIFY-TIMESTAMP") + resource = self.resource(request) + receiver = self.getRequiredHeader(request, "SIGNIFY-RECEIVER") - Returns: - headers (Hict): Modified headers with new Signature and Signature Input fields + 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") - if fields is None: - fields = self.DefaultFields + ckever = agent.agentHab.kevers[resource] + signages = ending.designature(signature) + cig = signages[0].markers["signify"] - header, qsig = ending.siginput("signify", method, path, headers, fields=fields, 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])) + 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") + + plaintext = agent.agentHab.decrypt(ser=cipher).decode("utf-8") + environ = buildEnviron(plaintext) + + # 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 headers + return agent, environ + + @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") + + rep.status = 200 + + for header in rep.headers.keys(): + if header.lower() in CORS_HEADERS: + continue + rep.delete_header(header) + + dest = Authenticator.resource(req) + 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") + + pubkey = pysodium.crypto_sign_pk_to_box_pk(ckever.verfers[0].raw) + raw = pysodium.crypto_box_seal(inner, pubkey) + rep.data = raw + + 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(): + rep.set_header(key, val) class SignatureValidationComponent(object): """ Validate Signature and Signature-Input header signatures """ - def __init__(self, agency, authn: Authenticater, allowed=None): + def __init__(self, agency, authn: Authenticator, allowed=None): """ Parameters: @@ -153,59 +149,89 @@ def __init__(self, agency, authn: Authenticater, allowed=None): self.authn = authn self.allowed = allowed - def process_request(self, req, resp): - """ Process request to ensure has a valid signature from caid + def process_request(self, req: ModifiableRequest, resp: falcon.Response): + """ Process request to ensure has a valid ESSR payload from caid Parameters: - req: Http request object - resp: Http response object - + req (ModifiableRequest): Falcon request object + resp (Response): Falcon response object """ - for path in self.allowed: if req.path.startswith(path): return - req.path = quote(req.path) - 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: + # Decrypt embedded HTTP req and inject into Falcon req + agent, environ = self.authn.unwrap(req) + req.replace(environ) + req.context.agent = agent + return + except (kering.AuthNError, ValueError): pass resp.complete = True # This short-circuits Falcon, skipping all further processing resp.status = falcon.HTTP_401 return - def process_response(self, req, rep, resource, req_succeeded): + 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. 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 (bool): True means the request was successfully handled """ - 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) - + 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 + ]) + body = response.data.decode("utf-8") if response.data else "" + return f"{status_line}\r\n{headers}\r\n\r\n{body}" diff --git a/tests/core/test_authing.py b/tests/core/test_authing.py index 7885f729..11f2d4e3 100644 --- a/tests/core/test_authing.py +++ b/tests/core/test_authing.py @@ -5,191 +5,228 @@ Testing httping utils """ +import pysodium +from unittest import mock +import json import falcon 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 -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 test_authenticater_unwrap(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.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) - - # 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 = testing.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) + otherAgent = agency.create(caid="ELbpFmMh3eiK5rDj-_7L6e3Yk_CGxLVbhBopMh65gWXD") + + req = testing.create_req(method="POST", path="/oobis") + with pytest.raises(kering.AuthNError) as e: + authn.unwrap(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 = testing.create_req(method="POST", path="/", body=raw) + with pytest.raises(ValueError) as e: + authn.unwrap(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) + assert str(e.value) == "Missing SIGNIFY-TIMESTAMP header" + + req.headers["SIGNIFY-TIMESTAMP"] = dt + with pytest.raises(ValueError) as e: + authn.unwrap(req) + assert str(e.value) == "Missing SIGNIFY-RESOURCE header" + + req.headers["SIGNIFY-RESOURCE"] = controller.pre + with pytest.raises(ValueError) as e: + authn.unwrap(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) + assert str(e.value) == "Unknown or invalid controller" 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 - assert authn.verify(req) - - headers = Hict([ - ("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'} - - - - 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) + # 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) + assert str(e.value) == "Unknown or invalid agent" + + req.headers["SIGNIFY-RECEIVER"] = "unknown-receiver" + with pytest.raises(kering.AuthNError) as e: + authn.unwrap(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) + assert str(e.value) == "Signature invalid" + + req = testing.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) + 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 = testing.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, caid=None): + def get(self): return self.agent class MockAuthN: - def __init__(self, valid=False, error=None): - self.valid = valid + def __init__(self, agent, environ, error=None): + self.agent = agent + self.environ = environ self.error = error - def verify(self, _): + def unwrap(self, _): if self.error is not None: raise self.error - return self.valid + return self.agent, self.environ @staticmethod def resource(_): return "" -def test_signature_validation(mockHelpingNowUTC): - vc = authing.SignatureValidationComponent(agency=MockAgency(), authn=MockAuthN(), allowed=["/test", "/reward"]) - - req = testing.create_req(method="GET", path="/boot") - rep = falcon.Response() - - vc.process_request(req, rep) - assert rep.complete is True - assert rep.status == falcon.HTTP_401 - - req = testing.create_req(method="POST", path="/boot") - rep = falcon.Response() - - vc.process_request(req, rep) - assert rep.complete is True - assert rep.status == falcon.HTTP_401 - - req = testing.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") - rep = falcon.Response() +def create_req(**kwargs): + return authing.ModifiableRequest(testing.create_environ(**kwargs)) - vc.process_request(req, rep) - assert rep.complete is False - assert rep.status == falcon.HTTP_200 +def test_signature_validation(mockHelpingNowUTC): agent = object() - vc = authing.SignatureValidationComponent(agency=MockAgency(agent=agent), authn=MockAuthN(valid=True), + 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 = testing.create_req(method="POST", path="/test") + 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 - req = testing.create_req(method="GET", path="/identifiers") + 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="/identifiers") + 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(error=kering.AuthNError())) + 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() @@ -197,7 +234,7 @@ def test_signature_validation(mockHelpingNowUTC): assert rep.complete is True assert rep.status == falcon.HTTP_401 - vc = authing.SignatureValidationComponent(agency=MockAgency(), authn=MockAuthN(error=ValueError())) + vc = authing.SignatureValidationComponent(agency=MockAgency(), authn=MockAuthN(agent=agent, environ=environ, error=ValueError())) req = testing.create_req(method="POST", path="/identifiers") rep = falcon.Response() @@ -210,23 +247,125 @@ def test_signature_validation(mockHelpingNowUTC): 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.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) - req = testing.create_req(method="POST", path="/reward") + 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 + + 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 + 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" 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"', + + # Signature will change each time due to crypto_box_seal + assert rep.headers == {'signature': mock.ANY, 'signify-resource': 'EDqDrGuzned0HOKFTLqd7m7O7WGE5zYIOHrlCq4EnWxy', - 'signify-timestamp': '2021-01-01T00:00:00.000000+00:00'} + '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_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.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.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.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'} From 0441be22337671c0d26c1f404cd7120faadd4272 Mon Sep 17 00:00:00 2001 From: iFergal Date: Fri, 17 Jan 2025 16:03:58 -0300 Subject: [PATCH 2/9] fix: expose headers for CORS --- src/keria/app/agenting.py | 3 ++- src/keria/core/authing.py | 1 + src/keria/core/httping.py | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/keria/app/agenting.py b/src/keria/app/agenting.py index d8dd1a2a..fd34a4d4 100644 --- a/src/keria/app/agenting.py +++ b/src/keria/app/agenting.py @@ -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='*', diff --git a/src/keria/core/authing.py b/src/keria/core/authing.py index bce5e6ae..cc9015ed 100644 --- a/src/keria/core/authing.py +++ b/src/keria/core/authing.py @@ -19,6 +19,7 @@ "access-control-allow-origin", "access-control-allow-methods", "access-control-allow-headers", + "access-control-expose-headers", "access-control-max-age" ] 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) From 3b080f85d55e13bd9575926fc1a5f48351c40442 Mon Sep 17 00:00:00 2001 From: iFergal Date: Wed, 22 Jan 2025 21:50:32 -0300 Subject: [PATCH 3/9] fix: handle rep.text responses (needs tests) --- src/keria/core/authing.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/keria/core/authing.py b/src/keria/core/authing.py index cc9015ed..ecca7e4f 100644 --- a/src/keria/core/authing.py +++ b/src/keria/core/authing.py @@ -117,7 +117,9 @@ def wrap(req, rep): 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 diger = coring.Diger(ser=raw, code=MtrDex.Blake3_256) payload = dict( @@ -234,5 +236,12 @@ def serializeResponse(protocol: str, response: falcon.Response): f"{key}: {value}" for key, value in response.headers.items() if key.lower() not in CORS_HEADERS ]) - body = response.data.decode("utf-8") if response.data else "" + + if response.text: + body = response.text.strip() + elif response.data: + body = response.data.decode("utf-8") + else: + body = "" + return f"{status_line}\r\n{headers}\r\n\r\n{body}" From 61e11c3f2cdd07441d5fc7d1cf38a966671d38af Mon Sep 17 00:00:00 2001 From: iFergal Date: Wed, 22 Jan 2025 21:56:21 -0300 Subject: [PATCH 4/9] refactor: remove unnecessary strip (was from debugging) [skip ci] --- src/keria/core/authing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/keria/core/authing.py b/src/keria/core/authing.py index ecca7e4f..dc932468 100644 --- a/src/keria/core/authing.py +++ b/src/keria/core/authing.py @@ -238,7 +238,7 @@ def serializeResponse(protocol: str, response: falcon.Response): ]) if response.text: - body = response.text.strip() + body = response.text elif response.data: body = response.data.decode("utf-8") else: From 6edad46d335395accf17aaa904226be2db989142 Mon Sep 17 00:00:00 2001 From: iFergal Date: Wed, 18 Jun 2025 18:41:28 +0100 Subject: [PATCH 5/9] feat: allow both signed headers and ESSR for authentication, signify client decides which --- src/keria/app/agenting.py | 6 +- src/keria/app/aiding.py | 3 +- src/keria/core/authing.py | 397 +++++++++++++++++++++++++++---------- tests/core/test_authing.py | 362 ++++++++++++++++++++------------- 4 files changed, 528 insertions(+), 240 deletions(-) diff --git a/src/keria/app/agenting.py b/src/keria/app/agenting.py index 29173d8e..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 Authenticator +from ..core.authing import SignedHeaderAuthenticator from ..core.keeping import RemoteManager from ..db import basing @@ -189,7 +189,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), @@ -197,7 +197,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 54ea4241..9119d5b7 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 dc932468..5fac7f19 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,187 @@ ] +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 or invalid controller") + + if resource not in agent.agentHab.kevers: + raise kering.AuthNError("unknown or invalid controller") + + inputs = ending.desiginput(siginput.encode("utf-8")) + inputs = [i for i in inputs if i.name == "signify"] + + if not inputs: + raise kering.AuthNError("todo") + + 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 +243,50 @@ 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.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 + + """ + 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 +300,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 + + 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() + + 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 + + @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 SignatureValidationComponent(object): - """ Validate Signature and Signature-Input header signatures """ - def __init__(self, agency, authn: Authenticator, allowed=None): +class AuthenticationMiddleware: + """ Authenticate incoming signed requests and sign outbound responses (optionally encrypted) """ + + def __init__(self, agency, authn: SignedHeaderAuthenticator, essrAuthn: ESSRAuthenticator = None, allowed=None): """ Parameters: - authn (Authenticater): Authenticator to validate signature headers on request + 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 11f2d4e3..ccf2366b 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,95 @@ 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): # Should fail if Agent hasn't resolved caid's KEL + authn.inbound(req) + + 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) # 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): # Should because the agent won't be found + authn.inbound(req) + + +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 +120,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 +143,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 +180,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 +226,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 +251,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 +260,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 +298,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 +314,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 +339,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 +353,115 @@ def test_build_environ(): 'wsgi.errors': mock.ANY, 'wsgi.input': mock.ANY, 'wsgi.url_scheme': 'https'} + + +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() From 1b457ca3660248e87762b68e6a8eb50d526fe022 Mon Sep 17 00:00:00 2001 From: iFergal Date: Thu, 19 Jun 2025 08:59:06 +0100 Subject: [PATCH 6/9] test: coverage for serializeResponse --- tests/core/test_authing.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/core/test_authing.py b/tests/core/test_authing.py index ccf2366b..c5580340 100644 --- a/tests/core/test_authing.py +++ b/tests/core/test_authing.py @@ -355,6 +355,40 @@ def test_build_environ(): '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 From ec5c93a9f73c39a47a58c90c649d599a846032a6 Mon Sep 17 00:00:00 2001 From: iFergal Date: Wed, 25 Jun 2025 14:29:19 +0100 Subject: [PATCH 7/9] feat: better error msgs and test coverage for signed headers --- src/keria/core/authing.py | 6 +++--- tests/core/test_authing.py | 40 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/keria/core/authing.py b/src/keria/core/authing.py index 5fac7f19..828d2538 100644 --- a/src/keria/core/authing.py +++ b/src/keria/core/authing.py @@ -110,16 +110,16 @@ def inbound(self, request: ModifiableRequest): agent = self.agency.get(resource) if agent is None: - raise kering.AuthNError("unknown or invalid controller") + raise kering.AuthNError("Unknown controller") if resource not in agent.agentHab.kevers: - raise kering.AuthNError("unknown or invalid controller") + 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("todo") + raise kering.AuthNError("Missing signify inputs in signature") for inputage in inputs: items = [] diff --git a/tests/core/test_authing.py b/tests/core/test_authing.py index c5580340..37244d84 100644 --- a/tests/core/test_authing.py +++ b/tests/core/test_authing.py @@ -71,8 +71,9 @@ def test_signed_header_authenticator(mockHelpingNowUTC): 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 + 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() @@ -80,6 +81,40 @@ def test_signed_header_authenticator(mockHelpingNowUTC): 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() @@ -101,8 +136,9 @@ def test_signed_header_authenticator(mockHelpingNowUTC): '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): # Should because the agent won't be found + 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): From b3d6181c211d7eefca12075f8a00d6e5749dbcaa Mon Sep 17 00:00:00 2001 From: iFergal Date: Thu, 3 Jul 2025 14:21:46 +0100 Subject: [PATCH 8/9] fix: unquote request path after verifying inbound ESSR --- src/keria/core/authing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/keria/core/authing.py b/src/keria/core/authing.py index 828d2538..53a32f96 100644 --- a/src/keria/core/authing.py +++ b/src/keria/core/authing.py @@ -174,7 +174,6 @@ def outbound(self, request: ModifiableRequest, response: falcon.Response): response (Response): Falcon response object """ - request.path = quote(request.path) agent = request.context.agent response.set_header('Signify-Resource', agent.agentHab.pre) @@ -251,6 +250,7 @@ def inbound(self, request: ModifiableRequest): 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 @@ -262,6 +262,7 @@ def outbound(self, request: ModifiableRequest, response: falcon.Response): 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") From 1d83e66140c8b8a1e4d25b341265215d48e5b314 Mon Sep 17 00:00:00 2001 From: iFergal Date: Thu, 3 Jul 2025 16:46:56 +0100 Subject: [PATCH 9/9] fix: calc content-length in environ by bytes not chars and test --- src/keria/core/authing.py | 4 ++-- tests/core/test_authing.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/keria/core/authing.py b/src/keria/core/authing.py index 53a32f96..21b5ae6e 100644 --- a/src/keria/core/authing.py +++ b/src/keria/core/authing.py @@ -328,10 +328,10 @@ def buildEnviron(raw: str) -> Dict[str, Any]: headers[header_name.strip()] = header_value.strip() i += 1 - body = "\n".join(lines[i + 1:]).strip() + body = "\n".join(lines[i + 1:]).strip().encode("utf-8") environ = { - "wsgi.input": BytesIO(body.encode("utf-8")), + "wsgi.input": BytesIO(body), "wsgi.errors": sys.stderr, "wsgi.url_scheme": splitUrl.scheme, "REQUEST_METHOD": method, diff --git a/tests/core/test_authing.py b/tests/core/test_authing.py index 37244d84..f69f56c8 100644 --- a/tests/core/test_authing.py +++ b/tests/core/test_authing.py @@ -390,6 +390,27 @@ def test_build_environ(): '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()