Improve HTTP signature handling
This commit is contained in:
parent
3f4a266157
commit
dbbfe4f788
2 changed files with 44 additions and 10 deletions
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue