diff --git a/.gitignore b/.gitignore index 9fd7acd..c3d8f02 100644 --- a/.gitignore +++ b/.gitignore @@ -206,4 +206,6 @@ marimo/_static/ marimo/_lsp/ __marimo__/ -private_key.pem +private_key*.pem + +devel/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 83b2ca4..848cb8c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,75 +1,78 @@ repos: - - hooks: - - additional_dependencies: - - aiohttp>=3.12.15 - - apmodel>=0.5.0 - - apsig>=0.5.4 - - charset-normalizer>=3.4.3 - - coverage>=7.10.7 - - fastapi>=0.116.1 - - httpcore[http2,socks]>=1.0.9 - - httpx>=0.28.1 - - pytest>=8.4.1 - - redis>=5.0.4 - - requests>=2.32.5 - - types-requests>=2.32.4.20250913 - - uvicorn>=0.35.0 - args: - - --fix - description: Run 'ruff' for extremely fast Python linting - entry: ruff check --force-exclude - id: ruff - language: python - minimum_pre_commit_version: 2.9.2 - name: ruff - require_serial: true - types_or: - - python - - pyi - - additional_dependencies: - - aiohttp>=3.12.15 - - apmodel>=0.5.0 - - apsig>=0.5.4 - - charset-normalizer>=3.4.3 - - coverage>=7.10.7 - - fastapi>=0.116.1 - - httpcore[http2,socks]>=1.0.9 - - httpx>=0.28.1 - - pytest>=8.4.1 - - redis>=5.0.4 - - requests>=2.32.5 - - types-requests>=2.32.4.20250913 - - uvicorn>=0.35.0 - args: [] - description: Run 'ruff format' for extremely fast Python formatting - entry: ruff format --force-exclude - id: ruff-format - language: python - minimum_pre_commit_version: 2.9.2 - name: ruff-format - require_serial: true - types_or: - - python - - pyi - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.6 - - hooks: - - additional_dependencies: - - aiohttp>=3.12.15 - - apmodel>=0.5.0 - - apsig>=0.5.4 - - charset-normalizer>=3.4.3 - - coverage>=7.10.7 - - fastapi>=0.116.1 - - httpcore[http2,socks]>=1.0.9 - - httpx>=0.28.1 - - pytest>=8.4.1 - - redis>=5.0.4 - - requests>=2.32.5 - - types-requests>=2.32.4.20250913 - - uvicorn>=0.35.0 - id: pyrefly-check - name: Pyrefly (type checking) - pass_filenames: false - repo: https://github.com/facebook/pyrefly-pre-commit - rev: 0.46.0 +- hooks: + - additional_dependencies: + - aiohttp>=3.12.15 + - apmodel>=0.5.1 + - apsig>=0.6.0 + - charset-normalizer>=3.4.3 + - coverage>=7.10.7 + - fastapi>=0.116.1 + - httpcore[http2,socks]>=1.0.9 + - httpx>=0.28.1 + - pytest-cov>=7.0.0 + - pytest>=8.4.1 + - redis>=5.0.4 + - requests>=2.32.5 + - types-requests>=2.32.4.20250913 + - uvicorn>=0.35.0 + args: + - --fix + description: Run 'ruff' for extremely fast Python linting + entry: ruff check --force-exclude + id: ruff + language: python + minimum_pre_commit_version: 2.9.2 + name: ruff + require_serial: true + types_or: + - python + - pyi + - additional_dependencies: + - aiohttp>=3.12.15 + - apmodel>=0.5.1 + - apsig>=0.6.0 + - charset-normalizer>=3.4.3 + - coverage>=7.10.7 + - fastapi>=0.116.1 + - httpcore[http2,socks]>=1.0.9 + - httpx>=0.28.1 + - pytest-cov>=7.0.0 + - pytest>=8.4.1 + - redis>=5.0.4 + - requests>=2.32.5 + - types-requests>=2.32.4.20250913 + - uvicorn>=0.35.0 + args: [] + description: Run 'ruff format' for extremely fast Python formatting + entry: ruff format --force-exclude + id: ruff-format + language: python + minimum_pre_commit_version: 2.9.2 + name: ruff-format + require_serial: true + types_or: + - python + - pyi + repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.6 +- hooks: + - additional_dependencies: + - aiohttp>=3.12.15 + - apmodel>=0.5.1 + - apsig>=0.6.0 + - charset-normalizer>=3.4.3 + - coverage>=7.10.7 + - fastapi>=0.116.1 + - httpcore[http2,socks]>=1.0.9 + - httpx>=0.28.1 + - pytest-cov>=7.0.0 + - pytest>=8.4.1 + - redis>=5.0.4 + - requests>=2.32.5 + - types-requests>=2.32.4.20250913 + - uvicorn>=0.35.0 + id: pyrefly-check + name: Pyrefly (type checking) + pass_filenames: false + repo: https://github.com/facebook/pyrefly-pre-commit + rev: 0.46.0 diff --git a/pyproject.toml b/pyproject.toml index 1232a99..466ca5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ requires-python = ">=3.11" dependencies = [ "aiohttp>=3.12.15", "apmodel>=0.5.1", - "apsig>=0.5.4", + "apsig>=0.6.0", "charset-normalizer>=3.4.3", "httpcore[http2,socks]>=1.0.9", "httpx>=0.28.1", @@ -35,10 +35,6 @@ server = [ [tool.uv] default-groups = "all" -[tool.uv.sources] -apmodel = { git = "https://github.com/fedi-libs/apmodel.git", branch = "develop" } -apsig = { git = "https://github.com/fedi-libs/apsig.git" } - [build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" diff --git a/rfc9421.py b/rfc9421.py new file mode 100644 index 0000000..64c024e --- /dev/null +++ b/rfc9421.py @@ -0,0 +1,374 @@ +import base64 +import datetime as dt +import email.utils +import json +from typing import Any, List, Tuple, cast + +import pytz +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa +from http_sf import parse +from http_sf.types import ( + BareItemType, + InnerListType, + ItemType, + ParamsType, +) +from multiformats import multibase, multicodec + +from apsig.draft.tools import calculate_digest +from apsig.exceptions import MissingSignature, VerificationFailed + + +class RFC9421Signer: + def __init__(self, private_key: rsa.RSAPrivateKey, key_id: str): + self.private_key = private_key + self.key_id = key_id + self.sign_headers = [ + "date", + "@method", + "@path", + "@authority", + "content-type", + "content-length", + ] + + def __build_signature_base( + self, special_keys: dict[str, str], headers: dict[str, str] + ) -> bytes: + headers_new = [] + headers = headers.copy() + for h in self.sign_headers: + if h in ["@method", "@path", "@authority"]: + v = special_keys.get(h) + + if v: + headers_new.append(f'"{h}": {v}') + else: + raise ValueError(f"Missing Value: {h}") + elif h == "@signature-params": + v = special_keys.get(h) + + if v: + headers_new.append(f'"{h}": {self.__generate_sig_input()}') + else: + raise ValueError(f"Missing Value: {h}") + else: + v_raw = headers.get(h) + if v_raw is not None: + v = v_raw.strip() + headers_new.append(f'"{h}": {v}') + else: + raise ValueError(f"Missing Header Value: {h}") + headers_new.append(f'"@signature-params": {self.__generate_sig_input()}') + return ("\n".join(headers_new)).encode("utf-8") + + def generate_signature_header(self, signature: bytes) -> str: + return base64.b64encode(signature).decode("utf-8") + + def __generate_sig_input(self): + param = "(" + target_len = len(self.sign_headers) + timestamp = dt.datetime.now(dt.UTC) + for p in self.sign_headers: + param += f'"{p}"' + if p != self.sign_headers[target_len - 1]: + param += " " + param += ");" + param += f"created={int(timestamp.timestamp())};" + param += 'alg="rsa-v1_5-sha256";' + param += f'keyid="{self.key_id}"' + return param + + def sign( + self, + method: str, + path: str, + host: str, + headers: dict, + body: bytes | dict = b"", + ): + if isinstance(body, dict): + body = json.dumps(body).encode("utf-8") + + headers = {k.lower(): v for k, v in headers.items()} + if not headers.get("date"): + headers["date"] = email.utils.formatdate(usegmt=True) + if not headers.get("content-length"): + headers["content-length"] = str(len(body)) + + special_keys = { + "@method": method.upper(), + "@path": path, + "@authority": host, + } + + base = self.__build_signature_base(special_keys, headers) + signed = self.private_key.sign(base, padding.PKCS1v15(), hashes.SHA256()) + headers_req = headers.copy() + headers_req["Signature"] = f"sig1=:{self.generate_signature_header(signed)}:" + headers_req["content-digest"] = f"sha-256=:{calculate_digest(body)}:" + headers_req["Signature-Input"] = f"sig1={self.__generate_sig_input()}" + return headers_req + + +class RFC9421Verifier: + def __init__( + self, + public_key: ed25519.Ed25519PublicKey + | rsa.RSAPublicKey + | ec.EllipticCurvePublicKey + | str, + method: str, + path: str, + host: str, + headers: dict[str, str], + body: bytes | dict | None = None, + clock_skew: int = 300, + ): + self.public_key: ( + ed25519.Ed25519PublicKey | rsa.RSAPublicKey | ec.EllipticCurvePublicKey + ) + + if isinstance(public_key, str): + codec, data = multicodec.unwrap(multibase.decode(public_key)) + match codec.name: + case "ed25519-pub": + self.public_key: ed25519.Ed25519PublicKey = ( + ed25519.Ed25519PublicKey.from_public_bytes(data) + ) + case "rsa-pub": + pubkey = serialization.load_pem_public_key(data) + if not isinstance(pubkey, rsa.RSAPublicKey): + raise TypeError("PublicKey must be ed25519 or RSA or ECDSA.") + self.public_key = pubkey + case "p256-pub": + pubkey = serialization.load_pem_public_key(data) + if not isinstance(pubkey, ec.EllipticCurvePublicKey): + raise TypeError("PublicKey must be ed25519 or RSA or ECDSA.") + self.public_key = pubkey + case "p384-pub": + pubkey = serialization.load_pem_public_key(data) + if not isinstance(pubkey, ec.EllipticCurvePublicKey): + raise TypeError("PublicKey must be ed25519 or RSA or ECDSA.") + self.public_key = pubkey + case _: + raise TypeError("PublicKey must be ed25519 or RSA or ECDSA.") + else: + self.public_key: ed25519.Ed25519PublicKey | rsa.RSAPublicKey = public_key + self.clock_skew = clock_skew + self.method = method.upper() + self.path = path + self.host = host + self.headers = {key.lower(): value for key, value in headers.items()} + + def __expect_value_and_params_member( + self, + member: Any, + ) -> Tuple[ItemType | InnerListType, ParamsType]: + if not isinstance(member, tuple) or len(member) != 2: + raise ValueError("expected a (value, params) tuple") + value, params = member + if not isinstance(params, dict): + raise ValueError("expected params to be a dict") + return cast(Tuple[ItemType | InnerListType, ParamsType], (value, params)) + + def __generate_sig_input( + self, headers: List[BareItemType], params: ParamsType + ) -> str: + created = params.get("created") + alg = params.get("alg") + keyid = params.get("keyid") + + if isinstance(created, dt.datetime): + created_timestamp = created + elif isinstance(created, int): + created_timestamp = dt.datetime.fromtimestamp(created) + elif isinstance(created, str): + created_timestamp = dt.datetime.fromtimestamp(int(created)) + else: + raise ValueError("Unknown created value") + request_time = created_timestamp.astimezone(pytz.utc) + current_time = dt.datetime.now(dt.UTC) + if abs((current_time - request_time).total_seconds()) > self.clock_skew: + raise VerificationFailed( + f"property created is too far from current time ({current_time}): {request_time}" + ) + + param = "(" + target_len = len(headers) + for p in headers: + param += f'"{p}"' + if p != headers[target_len - 1]: + param += " " + param += ");" + param += f"created={int(created_timestamp.timestamp())};" + param += f'alg="{alg}";' + param += f'keyid="{keyid}"' + return param + + def __rebuild_sigbase( + self, headers: List[BareItemType], params: ParamsType + ) -> bytes: + special_keys = { + "@method": self.method, + "@path": self.path, + "@authority": self.host, + } + base = [] + for h in cast(List[str], headers): + if h in ["@method", "@path", "@authority"]: + base.append(f'"{h}": {special_keys.get(h)}') + else: + v_raw = self.headers.get(h) + if v_raw is not None: + v = v_raw.strip() + base.append(f'"{h}": {v}') + else: + raise ValueError(f"Missing Header Value: {h}") + base.append( + f'"@signature-params": {self.__generate_sig_input(headers=headers, params=params)}' + ) + return ("\n".join(base)).encode("utf-8") + + def verify(self, raise_on_fail: bool = False) -> str | None: + signature = self.headers.get("signature") + if not signature: + if raise_on_fail: + raise MissingSignature("Signature header is missing") + return None + + signature_input = self.headers.get("signature-input") + if not signature_input: + if raise_on_fail: + raise MissingSignature("Signature-Input header is missing") + return None + + signature_input_parsed = parse( + signature_input.encode("utf-8"), tltype="dictionary" + ) + signature_parsed = parse(signature.encode("utf-8"), tltype="dictionary") + + if not isinstance(signature_input_parsed, dict): + raise VerificationFailed( + f"Unsupported Signature-Input type: {type(signature_input_parsed)}" + ) + + if not isinstance(signature_parsed, dict): + raise VerificationFailed( + f"Unsupported Signature type: {type(signature_parsed)}" + ) + + for k, v in signature_input_parsed.items(): + try: + value, params = self.__expect_value_and_params_member(v) + if isinstance(value, list): + headers: List[BareItemType] = [ + itm[0] if isinstance(itm, tuple) else itm for itm in value + ] + else: + raise ValueError( + "expected the value to be an inner-list (list of items)" + ) + + created = params.get("created") + key_id = str(params.get("keyid")) + alg = params.get("alg") + + if not created: + raise VerificationFailed("created not found.") + if not key_id: + raise VerificationFailed("keyid not found.") + if not alg: + raise VerificationFailed("alg not found.") + if alg not in [ + "ed25519", + "rsa-v1_5-sha256", + "rsa-v1_5-sha512", + "rsa-pss-sha512", + ]: + raise VerificationFailed(f"Unsupported algorithm: {alg}") + + sigi = self.__rebuild_sigbase(headers, params) + signature_bytes = signature_parsed.get(k) + if not isinstance(signature_bytes, tuple): + raise VerificationFailed( + f"Unknown Signature: {type(signature_bytes)}" + ) + + sig_val = None + for sig in cast(InnerListType, signature_bytes): + if isinstance(sig, bytes): + sig_val = sig + break + if sig_val is None: + raise ValueError("No Signature found.") + try: + match alg: + case "ed25519": + if not isinstance( + self.public_key, ed25519.Ed25519PublicKey + ): + raise VerificationFailed("Algorithm missmatch.") + self.public_key.verify(sig_val, sigi) + case "rsa-v1_5-sha256": + if not isinstance(self.public_key, rsa.RSAPublicKey): + raise VerificationFailed("Algorithm missmatch.") + self.public_key.verify( + sig_val, + sigi, + padding.PKCS1v15(), + hashes.SHA256(), + ) + case "rsa-v1_5-sha512": + if not isinstance(self.public_key, rsa.RSAPublicKey): + raise VerificationFailed("Algorithm missmatch.") + self.public_key.verify( + sig_val, + sigi, + padding.PKCS1v15(), + hashes.SHA512(), + ) + case "rsa-pss-sha512": + if not isinstance(self.public_key, rsa.RSAPublicKey): + raise VerificationFailed("Algorithm missmatch.") + self.public_key.verify( + sig_val, + sigi, + padding.PSS( + mgf=padding.MGF1(hashes.SHA512()), + salt_length=hashes.SHA512().digest_size, + ), + hashes.SHA512(), + ) + case "ecdsa-p256-sha256": + if not isinstance( + self.public_key, ec.EllipticCurvePublicKey + ): + raise VerificationFailed("Algorithm missmatch.") + self.public_key.verify( + sig_val, + sigi, + ec.ECDSA(hashes.SHA256()), + ) + case "ecdsa-p384-sha384": + if not isinstance( + self.public_key, ec.EllipticCurvePublicKey + ): + raise VerificationFailed("Algorithm missmatch.") + self.public_key.verify( + sig_val, + sigi, + ec.ECDSA(hashes.SHA384()), + ) + return key_id + except Exception as e: + if raise_on_fail: + raise VerificationFailed(str(e)) + return None + except ValueError: + continue + + if raise_on_fail: + raise VerificationFailed("RFC9421 Signature verification failed.") + return None diff --git a/src/apkit/_version.py b/src/apkit/_version.py index 96b0a27..bea3035 100644 --- a/src/apkit/_version.py +++ b/src/apkit/_version.py @@ -28,7 +28,7 @@ commit_id: COMMIT_ID __commit_id__: COMMIT_ID -__version__ = version = '0.3.2.post1.dev87+gb584b8cf1.d20251223' -__version_tuple__ = version_tuple = (0, 3, 2, 'post1', 'dev87', 'gb584b8cf1.d20251223') +__version__ = version = '0.3.3.post1.dev36+gc65fc9498' +__version_tuple__ = version_tuple = (0, 3, 3, 'post1', 'dev36', 'gc65fc9498') __commit_id__ = commit_id = None diff --git a/src/apkit/client/_common.py b/src/apkit/client/_common.py index 4f7a69d..a3894d4 100644 --- a/src/apkit/client/_common.py +++ b/src/apkit/client/_common.py @@ -1,12 +1,13 @@ import datetime import json +import urllib.parse import warnings +from collections.abc import Mapping from typing import ( Any, Dict, Iterable, List, - Mapping, Optional, Tuple, Union, @@ -16,14 +17,18 @@ import apsig from apmodel.types import ActivityPubModel from apsig import draft +from apsig.rfc9421 import RFC9421Signer from cryptography.hazmat.primitives.asymmetric import ed25519, rsa from ..types import ActorKey -from .exceptions import NotImplementedWarning from .models import Resource, WebfingerResult -def ensure_user_agent_and_reconstruct(headers: Any, user_agent: str) -> Dict[str, str]: +def reconstruct_headers( + headers: Any, + user_agent: str, + json: Optional[dict | ActivityPubModel | Any] = None, +) -> Dict[str, str]: processed_headers: Dict[str, Any] = {} match headers: @@ -48,6 +53,18 @@ def ensure_user_agent_and_reconstruct(headers: Any, user_agent: str) -> Dict[str processed_headers["user-agent"] = user_agent processed_headers["user-agent_original_key"] = "User-Agent" + if json: + if isinstance(json, ActivityPubModel): + if "content-type" not in processed_headers: + processed_headers[ + "content-type" + ] = "application/activity+json; charset=UTF-8" + processed_headers["content-type_original_key"] = "Content-Type" + elif isinstance(json, dict): + if "content-type" not in processed_headers: + processed_headers["content-type"] = "application/json" + processed_headers["content-type_original_key"] = "Content-Type" + final_headers: Dict[str, str] = {} for key_lower, value in processed_headers.items(): if key_lower.endswith("_original_key"): @@ -87,14 +104,38 @@ def sign_request( for signature in signatures: if isinstance(signature.private_key, rsa.RSAPrivateKey): if "rfc9421" in sign_with and not signed_rfc9421: - warnings.warn( - 'This signature spec "rfc9421" is not implemented yet.', - category=NotImplementedWarning, - stacklevel=2, - ) - signed_rfc9421 = True + if "draft-cavage" in sign_with: + warnings.warn( + "Draft and RFC9421 Signing is exclusive. " + "Legacy Draft mode is enabled to maintain compatibility. " + "The RFC 9421 (Structured Fields) signing logic will not be applied to this message.", + UserWarning, + ) + signed_rfc9421 = True + else: + parsed_url = urllib.parse.urlparse(url) + if parsed_url.hostname: + rfc_signer = RFC9421Signer( + signature.private_key, signature.key_id + ) + headers = rfc_signer.sign( + headers=dict(headers) if headers else {}, + method="POST", + host=parsed_url.hostname, + path=parsed_url.path, + body=body if body else b"", + ) + signed_rfc9421 = True if "draft-cavage" in sign_with and not signed_cavage: + if "rfc9421" in sign_with: + warnings.warn( + "Draft and RFC9421 Signing is exclusive. " + "Legacy Draft mode is enabled to maintain compatibility. " + "The RFC 9421 (Structured Fields) signing logic will not be applied to this message.", + UserWarning, + ) + signed_rfc9421 = True signer = draft.Signer( headers=dict(headers) if headers else {}, method="POST", diff --git a/src/apkit/client/asyncio/actor.py b/src/apkit/client/asyncio/actor.py index bb32d4f..0396110 100644 --- a/src/apkit/client/asyncio/actor.py +++ b/src/apkit/client/asyncio/actor.py @@ -19,9 +19,7 @@ async def resolve( headers: dict = {"Accept": "application/jrd+json"}, ) -> models.WebfingerResult: """Resolves an actor's profile from a remote server asynchronously.""" - headers = _common.ensure_user_agent_and_reconstruct( - headers, self.__client.user_agent - ) + headers = _common.reconstruct_headers(headers, self.__client.user_agent) resource = models.Resource(username=username, host=host) url = _common.build_webfinger_url(host=host, resource=resource) @@ -37,7 +35,7 @@ async def resolve( async def fetch( self, url: str, headers: dict = {"Accept": "application/activity+json"} ) -> ActivityPubModel | dict | list | str | None: - headers = _common.ensure_user_agent_and_reconstruct( + headers = _common.reconstruct_headers( headers if headers else {}, self.__client.user_agent ) async with self.__client.get(url, headers=headers) as resp: diff --git a/src/apkit/client/asyncio/client.py b/src/apkit/client/asyncio/client.py index afbe139..621126f 100644 --- a/src/apkit/client/asyncio/client.py +++ b/src/apkit/client/asyncio/client.py @@ -38,7 +38,7 @@ from ..._version import __version__ from ...types import ActorKey -from .._common import ensure_user_agent_and_reconstruct, sign_request +from .._common import reconstruct_headers, sign_request from .actor import ActorFetcher from .types import ActivityPubClientResponse, _RequestContextManager @@ -164,9 +164,7 @@ async def _request( "fep8b32", ], ) -> ActivityPubClientResponse: - headers = ensure_user_agent_and_reconstruct( - headers if headers else {}, self.user_agent - ) + headers = reconstruct_headers(headers if headers else {}, self.user_agent, json) if signatures != [] and sign_with: j, headers = await asyncio.to_thread( sign_request, diff --git a/src/apkit/client/sync/actor.py b/src/apkit/client/sync/actor.py index e915129..c800f22 100644 --- a/src/apkit/client/sync/actor.py +++ b/src/apkit/client/sync/actor.py @@ -19,9 +19,7 @@ def resolve( headers: dict = {"Accept": "application/jrd+json"}, ) -> models.WebfingerResult: """Resolves an actor's profile from a remote server.""" - headers = _common.ensure_user_agent_and_reconstruct( - headers, self.__client.user_agent - ) + headers = _common.reconstruct_headers(headers, self.__client.user_agent) resource = models.Resource(username=username, host=host) url = _common.build_webfinger_url(host=host, resource=resource) @@ -37,9 +35,7 @@ def resolve( def fetch( self, url: str, headers: dict = {"Accept": "application/activity+json"} ) -> ActivityPubModel | dict | list | str | None: - headers = _common.ensure_user_agent_and_reconstruct( - headers, self.__client.user_agent - ) + headers = _common.reconstruct_headers(headers, self.__client.user_agent) resp = self.__client.get( url, headers=headers, diff --git a/src/apkit/client/sync/client.py b/src/apkit/client/sync/client.py index 0639314..c8ee954 100644 --- a/src/apkit/client/sync/client.py +++ b/src/apkit/client/sync/client.py @@ -8,7 +8,7 @@ from ..._version import __version__ from ...types import ActorKey -from .._common import ensure_user_agent_and_reconstruct, sign_request +from .._common import reconstruct_headers, sign_request from .actor import ActorFetcher from .exceptions import TooManyRedirectsError from .types import Response @@ -59,7 +59,7 @@ def request( ) -> Response: if not self.__http: raise NotImplementedError - headers = ensure_user_agent_and_reconstruct(headers, self.user_agent) + headers = reconstruct_headers(headers, self.user_agent, content) if content is not None: content = self.__transform_to_bytes(content) if signatures != []: diff --git a/src/apkit/helper/inbox.py b/src/apkit/helper/inbox.py index 7a2db4d..b45ef3a 100644 --- a/src/apkit/helper/inbox.py +++ b/src/apkit/helper/inbox.py @@ -3,18 +3,20 @@ from typing import Any, Optional import apmodel +import http_sf from apmodel import Activity from apmodel.core.link import Link from apmodel.vocab.actor import Actor from apsig import KeyUtil, LDSignature, ProofVerifier from apsig.draft.verify import Verifier from apsig.exceptions import ( - MissingSignature, - UnknownSignature, - VerificationFailed, + MissingSignatureError, + UnknownSignatureError, + VerificationFailedError, ) +from apsig.rfc9421 import RFC9421Verifier from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ed25519, rsa +from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey @@ -57,6 +59,107 @@ async def __get_signature_from_kv(self, key_id: str) -> tuple[Optional[str], boo cache = True return public_key, cache + async def __verify_rfc9421( + self, + body: bytes, + url, + method: str, + headers: dict, + no_check_cache: bool = False, + ) -> bool: + body_json = json.loads(body) + activity = apmodel.load(body_json) + if not isinstance(activity, Activity): + raise ValueError("unsupported model type") + + signature_input_header = headers.get("signature-input") + if not signature_input_header: + raise MissingSignatureError("signature-input header is missing") + + signature_input_parsed = http_sf.parse( + signature_input_header.encode("utf-8"), tltype="dictionary" + ) + if not isinstance(signature_input_parsed, dict): + raise VerificationFailedError( + f"Unsupported Signature-Input type: {type(signature_input_parsed)}" + ) + + async def _verify_with_key( + key_id: str, + public_key_obj: Any, + is_cache: bool, + ) -> bool: + try: + verifier = RFC9421Verifier( + public_key=public_key_obj, + method=method, + path=url.path, + host=url.netloc.decode("utf-8"), + headers=headers, + ) + verified_key_id = verifier.verify(raise_on_fail=True) + if verified_key_id: + if not is_cache: + if isinstance( + public_key_obj, + (rsa.RSAPublicKey, ec.EllipticCurvePublicKey), + ): + pem = public_key_obj.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + await self.config.kv.async_set( + f"signature:{key_id}", pem.decode("utf-8") + ) + elif isinstance(public_key_obj, ed25519.Ed25519PublicKey): + ku = KeyUtil(public_key_obj) + await self.config.kv.async_set( + f"signature:{key_id}", ku.encode_multibase() + ) + return True + except VerificationFailedError as e: + if is_cache: + return False # Will be retried with fresh key + raise e + return False + + for _, v in signature_input_parsed.items(): + if not isinstance(v, tuple) or len(v) != 2: + continue + _, params = v + key_id = params.get("keyid") + if not key_id: + continue + key_id = str(key_id) + + # Try with cached key first + public_key_obj = None + cache = False + if not no_check_cache: + public_key_pem, cache = await self.__get_signature_from_kv(key_id) + if public_key_pem: + try: + ku = KeyUtil() + public_key_obj = ku.decode_multibase(public_key_pem) + except Exception: + try: + public_key_obj = serialization.load_pem_public_key( + public_key_pem.encode("utf-8") + ) + except ValueError: + self.logger.warning(f"Failed to load cached key {key_id}") + if public_key_obj and await _verify_with_key(key_id, public_key_obj, True): + return True + + actor = await self.__fetch_actor(activity) + if actor: + key_from_actor = actor.get_key(key_id) + if key_from_actor and key_from_actor.public_key: + if await _verify_with_key(key_id, key_from_actor.public_key, False): + return True + + return False + async def __verify_draft( self, body: bytes, @@ -71,7 +174,7 @@ async def __verify_draft( signature_parts = self.__get_draft_signature_parts(signature_header) key_id = signature_parts.get("keyId") if not key_id: - raise MissingSignature("keyId does not exist.") + raise MissingSignatureError("keyId does not exist.") cache = False public_key = None if not no_check_cache: @@ -90,32 +193,36 @@ async def __verify_draft( and isinstance(public_key.public_key, RSAPublicKey) ): verifier = Verifier( - public_key.public_key, method, url, headers, body + public_key.public_key, + method, + str(url), + headers, + body, ) try: verifier.verify(raise_on_fail=True) - if isinstance(public_key, rsa.RSAPublicKey): + if isinstance(public_key.public_key, rsa.RSAPublicKey): await self.config.kv.async_set( f"signature:{key_id}", - public_key.public_bytes( + public_key.public_key.public_bytes( serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo, - ), + ).decode("utf-8"), ) return True except Exception as e: if not cache: - raise VerificationFailed(f"{str(e)}") + raise VerificationFailedError(f"{str(e)}") else: return await self.__verify_draft( body, url, method, headers, no_check_cache=True ) else: - raise VerificationFailed("publicKey does not exist.") + raise VerificationFailedError("publicKey does not exist.") else: raise ValueError("unsupported model type") else: - raise MissingSignature("this is not http signed activity.") + raise MissingSignatureError("this is not http signed activity.") async def __verify_proof(self, body: bytes, no_check_cache: bool = False) -> bool: body_json = json.loads(body) @@ -154,19 +261,19 @@ async def __verify_proof(self, body: bytes, no_check_cache: bool = False) -> boo return True except Exception as e: if not cache: - raise VerificationFailed(f"{str(e)}") + raise VerificationFailedError(f"{str(e)}") else: return await self.__verify_proof( body, no_check_cache=True ) else: - raise VerificationFailed("publicKey does not exist.") + raise VerificationFailedError("publicKey does not exist.") else: raise ValueError("unsupported model type") else: - raise MissingSignature("verificationMethod does not exist.") + raise MissingSignatureError("verificationMethod does not exist.") else: - raise MissingSignature("this is not signed activity.") + raise MissingSignatureError("this is not signed activity.") async def __verify_ld(self, body: bytes, no_check_cache: bool = False) -> bool: ld = LDSignature() @@ -176,7 +283,7 @@ async def __verify_ld(self, body: bytes, no_check_cache: bool = False) -> bool: if isinstance(signature, dict): creator: Optional[str] = signature.get("creator") if creator is None: - raise MissingSignature("creator does not exist.") + raise MissingSignatureError("creator does not exist.") cache = False public_key = None if not no_check_cache: @@ -207,22 +314,29 @@ async def __verify_ld(self, body: bytes, no_check_cache: bool = False) -> bool: ), ) return True - raise VerificationFailed("publicKey does not exist.") + raise VerificationFailedError("publicKey does not exist.") except ( - UnknownSignature, - MissingSignature, - VerificationFailed, + UnknownSignatureError, + MissingSignatureError, + VerificationFailedError, ) as e: if not cache: - raise VerificationFailed(f"{str(e)}") + raise VerificationFailedError(f"{str(e)}") else: return await self.__verify_ld(body, no_check_cache=True) else: - raise VerificationFailed("publicKey does not exist.") + raise VerificationFailedError("publicKey does not exist.") else: raise ValueError("unsupported model type") async def verify(self, body: bytes, url, method, headers: dict) -> bool: + try: + rfc9421 = await self.__verify_rfc9421(body, url, method, headers) + if rfc9421: + self.logger.debug("RFC9421 verification successful") + return True + except Exception as e: + self.logger.debug(f"RFC9421 verification failed; {str(e)}") try: proof = await self.__verify_proof(body) if proof: diff --git a/uv.lock b/uv.lock index 3bd8186..b00e690 100644 --- a/uv.lock +++ b/uv.lock @@ -199,8 +199,8 @@ lint = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.12.15" }, - { name = "apmodel", git = "https://github.com/fedi-libs/apmodel.git?branch=develop" }, - { name = "apsig", git = "https://github.com/fedi-libs/apsig.git" }, + { name = "apmodel", specifier = ">=0.5.1" }, + { name = "apsig", specifier = ">=0.6.0" }, { name = "charset-normalizer", specifier = ">=3.4.3" }, { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.116.1" }, { name = "httpcore", extras = ["http2", "socks"], specifier = ">=1.0.9" }, @@ -230,7 +230,7 @@ lint = [ [[package]] name = "apmodel" version = "0.5.1" -source = { git = "https://github.com/fedi-libs/apmodel.git?branch=develop#9a9c00f8435d1466d4fa7299f9afbd283fa864f2" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "multiformats" }, @@ -238,20 +238,29 @@ dependencies = [ { name = "pyld", extra = ["aiohttp", "requests"] }, { name = "typing-extensions" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/9e/42/9519ea411e329137e333e704acfcb2c8f8ac1e2904dbad77afe28a124f4b/apmodel-0.5.1.tar.gz", hash = "sha256:0e6974a7d6cad6c82db5a43caad6d0aac6197463484b31470b9591c6fa3277a4", size = 221722, upload-time = "2025-12-23T07:47:01.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/16/20225cb4923bbec98cbebf004fb5de717dad5be0e0ffd15967399dd40e11/apmodel-0.5.1-py3-none-any.whl", hash = "sha256:6fbb8e7de0cdc917508c43c7de59fa062bfc6b5ee5b34c45107118699e21c5ea", size = 68257, upload-time = "2025-12-23T07:46:59.851Z" }, +] [[package]] name = "apsig" -version = "0.5.4.post1.dev0+g978995e92.d20251223" -source = { git = "https://github.com/fedi-libs/apsig.git#978995e928630382fd165e813b992c70f9cc03b1" } +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "authlib" }, { name = "cryptography" }, + { name = "http-sf" }, { name = "jcs" }, { name = "multiformats" }, - { name = "pyfill" }, { name = "pyld", extra = ["aiohttp", "requests"] }, { name = "pytz" }, { name = "typing-extensions" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/9f/99/68ed305078b58b2f79539f9e4fce210b2b63318cf6ea56821490a259a7c0/apsig-0.6.0.tar.gz", hash = "sha256:9bf17c7978ff45f21313a40f0255dd1fae35cca61ef7e613fb70274ca64ec823", size = 151386, upload-time = "2025-12-28T21:23:52.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/6f/7f4293cdfc39a2869bd95402ae4d680ea42e3578c6064e32700c918e7647/apsig-0.6.0-py3-none-any.whl", hash = "sha256:1a4d742f13deb0b3a344ee69e3edbdc3c091a03d0b47a40ce70966b2e637ceb5", size = 20242, upload-time = "2025-12-28T21:23:51.285Z" }, +] [[package]] name = "async-timeout" @@ -271,6 +280,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "authlib" +version = "1.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, +] + [[package]] name = "babel" version = "2.17.0" @@ -817,6 +838,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, ] +[[package]] +name = "http-sf" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/3a/68604f0071830a0a0054a2f521063807a1bc23341df32926fb61607f95a6/http_sf-1.0.7.tar.gz", hash = "sha256:862b8cd7c386cbcdd382f499cdfbf66eebc48fc4f0e974bfea483fd001265f96", size = 18371, upload-time = "2025-11-26T04:05:18.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/b9/9d68c4fad07019060bff4518765d4ea56363f4f4d2d8aea120ca97d09d3c/http_sf-1.0.7-py3-none-any.whl", hash = "sha256:8eab6db3194fe3ecb2ed01766e53d85b75184f413af94ecedef00d28f8b82320", size = 23154, upload-time = "2025-11-26T04:05:17.186Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -1573,15 +1606,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] -[[package]] -name = "pyfill" -version = "0.1.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/31/594dcd00ec7a13e7a847d8b3c14a9be74d785feaf7812f3313bbe2a97854/pyfill-0.1.3.tar.gz", hash = "sha256:f8416ca329c5c61da9323527b3f8f263f9ac71139d00ac3f5d2fa1398afca274", size = 4186, upload-time = "2025-02-28T08:45:22.981Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/61/c76ae9a76a0951c2cef4169056433b6900d470575a71af08321f5e46be9f/pyfill-0.1.3-py3-none-any.whl", hash = "sha256:f6057ac9ab418855f278a21b02082a80f969b2decd15229a476c9f74a2f353d7", size = 5039, upload-time = "2025-02-28T08:45:21.687Z" }, -] - [[package]] name = "pygments" version = "2.19.2"