Improve HTTP signature handling

main
Thomas Sileo 2022-07-21 21:49:42 +02:00
parent 3f4a266157
commit dbbfe4f788
2 changed files with 44 additions and 10 deletions

View File

@ -1,13 +1,10 @@
"""Implements HTTP signature for Flask requests.
Mastodon instances won't accept requests that are not signed using this scheme.
"""
import base64 import base64
import hashlib import hashlib
import typing import typing
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from datetime import timedelta
from datetime import timezone
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import MutableMapping from typing import MutableMapping
@ -18,6 +15,7 @@ import httpx
from cachetools import LFUCache from cachetools import LFUCache
from Crypto.Hash import SHA256 from Crypto.Hash import SHA256
from Crypto.Signature import PKCS1_v1_5 from Crypto.Signature import PKCS1_v1_5
from dateutil.parser import parse
from loguru import logger from loguru import logger
from sqlalchemy import select from sqlalchemy import select
@ -27,6 +25,7 @@ from app.config import KEY_PATH
from app.database import AsyncSession from app.database import AsyncSession
from app.database import get_db_session from app.database import get_db_session
from app.key import Key from app.key import Key
from app.utils.datetime import now
_KEY_CACHE: MutableMapping[str, Key] = LFUCache(256) _KEY_CACHE: MutableMapping[str, Key] = LFUCache(256)
@ -38,9 +37,17 @@ def _build_signed_string(
headers: Any, headers: Any,
body_digest: str | None, body_digest: str | None,
sig_data: dict[str, Any], sig_data: dict[str, Any],
) -> str: ) -> tuple[str, datetime | None]:
signature_date: datetime | None = None
out = [] out = []
for signed_header in signed_headers.split(" "): for signed_header in signed_headers.split(" "):
if signed_header == "(created)":
signature_date = datetime.fromtimestamp(int(sig_data["created"])).replace(
tzinfo=timezone.utc
)
elif signed_header == "date":
signature_date = parse(headers["date"])
if signed_header == "(request-target)": if signed_header == "(request-target)":
out.append("(request-target): " + method.lower() + " " + path) out.append("(request-target): " + method.lower() + " " + path)
elif signed_header == "digest" and body_digest: elif signed_header == "digest" and body_digest:
@ -53,7 +60,7 @@ def _build_signed_string(
) )
else: else:
out.append(signed_header + ": " + headers[signed_header]) out.append(signed_header + ": " + headers[signed_header])
return "\n".join(out) return "\n".join(out), signature_date
def _parse_sig_header(val: Optional[str]) -> Optional[Dict[str, str]]: def _parse_sig_header(val: Optional[str]) -> Optional[Dict[str, str]]:
@ -111,6 +118,7 @@ async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
actor = await ap.fetch(key_id, disable_httpsig=False) actor = await ap.fetch(key_id, disable_httpsig=False)
else: else:
raise raise
if actor["type"] == "Key": if actor["type"] == "Key":
# The Key is not embedded in the Person # The Key is not embedded in the Person
k = Key(actor["owner"], actor["id"]) k = Key(actor["owner"], actor["id"])
@ -134,6 +142,8 @@ class HTTPSigInfo:
has_valid_signature: bool has_valid_signature: bool
signed_by_ap_actor_id: str | None = None signed_by_ap_actor_id: str | None = None
is_ap_actor_gone: bool = False is_ap_actor_gone: bool = False
is_unsupported_algorithm: bool = False
is_expired: bool = False
async def httpsig_checker( async def httpsig_checker(
@ -147,8 +157,15 @@ async def httpsig_checker(
logger.info("No HTTP signature found") logger.info("No HTTP signature found")
return HTTPSigInfo(has_valid_signature=False) return HTTPSigInfo(has_valid_signature=False)
if alg := hsig.get("algorithm") not in ["rsa-sha256", "hs2019"]:
logger.info(f"Unsupported HTTP sig algorithm: {alg}")
return HTTPSigInfo(
has_valid_signature=False,
is_unsupported_algorithm=True,
)
logger.debug(f"hsig={hsig}") logger.debug(f"hsig={hsig}")
signed_string = _build_signed_string( signed_string, signature_date = _build_signed_string(
hsig["headers"], hsig["headers"],
request.method, request.method,
request.url.path, request.url.path,
@ -157,6 +174,14 @@ async def httpsig_checker(
hsig, hsig,
) )
# Sanity checks on the signature date
if signature_date is None or now() - signature_date > timedelta(hours=12):
logger.info(f"Signature expired: {signature_date=}")
return HTTPSigInfo(
has_valid_signature=False,
is_expired=True,
)
try: try:
k = await _get_public_key(db_session, hsig["keyId"]) k = await _get_public_key(db_session, hsig["keyId"])
except (ap.ObjectIsGoneError, ap.ObjectNotFoundError): except (ap.ObjectIsGoneError, ap.ObjectNotFoundError):
@ -180,6 +205,7 @@ async def enforce_httpsig(
request: fastapi.Request, request: fastapi.Request,
httpsig_info: HTTPSigInfo = fastapi.Depends(httpsig_checker), httpsig_info: HTTPSigInfo = fastapi.Depends(httpsig_checker),
) -> HTTPSigInfo: ) -> HTTPSigInfo:
"""FastAPI Depends"""
if not httpsig_info.has_valid_signature: if not httpsig_info.has_valid_signature:
logger.warning(f"Invalid HTTP sig {httpsig_info=}") logger.warning(f"Invalid HTTP sig {httpsig_info=}")
body = await request.body() body = await request.body()
@ -191,7 +217,13 @@ async def enforce_httpsig(
logger.info("Let's make Mastodon happy, returning a 202") logger.info("Let's make Mastodon happy, returning a 202")
raise fastapi.HTTPException(status_code=202) raise fastapi.HTTPException(status_code=202)
raise fastapi.HTTPException(status_code=401, detail="Invalid HTTP sig") detail = "Invalid HTTP sig"
if httpsig_info.is_unsupported_algorithm:
detail = "Unsupported signature algorithm, must be rsa-sha256 or hs2019"
elif httpsig_info.is_expired:
detail = "Signature expired"
raise fastapi.HTTPException(status_code=401, detail=detail)
return httpsig_info return httpsig_info
@ -219,7 +251,7 @@ class HTTPXSigAuth(httpx.Auth):
else: else:
sigheaders = "(request-target) user-agent host date accept" sigheaders = "(request-target) user-agent host date accept"
to_be_signed = _build_signed_string( to_be_signed, _ = _build_signed_string(
sigheaders, r.method, r.url.path, r.headers, bodydigest, {} sigheaders, r.method, r.url.path, r.headers, bodydigest, {}
) )
if not self.key.privkey: if not self.key.privkey:

View File

@ -76,6 +76,8 @@ _RESIZED_CACHE: MutableMapping[tuple[str, int], tuple[bytes, str, Any]] = LFUCac
# TODO(ts): # TODO(ts):
# #
# Next: # Next:
# - show pending follow request (and prevent double follow?)
# - a way to add alt text on image (maybe via special markup in content?)
# - UI support for updating posts # - UI support for updating posts
# - Support for processing update # - Support for processing update
# - Article support # - Article support