active-boxes 0.0.1.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- active_boxes/__init__.py +12 -0
- active_boxes/__version__.py +11 -0
- active_boxes/activitypub.py +984 -0
- active_boxes/backend.py +128 -0
- active_boxes/collection.py +70 -0
- active_boxes/content_helper.py +69 -0
- active_boxes/errors.py +92 -0
- active_boxes/httpsig.py +145 -0
- active_boxes/key.py +63 -0
- active_boxes/linked_data_sig.py +83 -0
- active_boxes/urlutils.py +66 -0
- active_boxes/webfinger.py +92 -0
- active_boxes-0.0.1.dev2.dist-info/LICENSE +22 -0
- active_boxes-0.0.1.dev2.dist-info/METADATA +45 -0
- active_boxes-0.0.1.dev2.dist-info/RECORD +16 -0
- active_boxes-0.0.1.dev2.dist-info/WHEEL +4 -0
active_boxes/backend.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import binascii
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import typing
|
|
6
|
+
from typing import Any
|
|
7
|
+
from typing import Dict
|
|
8
|
+
from typing import List
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from .__version__ import __version__
|
|
14
|
+
from .collection import parse_collection
|
|
15
|
+
from .errors import ActivityGoneError
|
|
16
|
+
from .errors import ActivityNotFoundError
|
|
17
|
+
from .errors import ActivityUnavailableError
|
|
18
|
+
from .errors import NotAnActivityError
|
|
19
|
+
from .urlutils import URLLookupFailedError
|
|
20
|
+
from .urlutils import check_url as check_url
|
|
21
|
+
|
|
22
|
+
if typing.TYPE_CHECKING:
|
|
23
|
+
from active_boxes import activitypub as ap # noqa: type checking
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Backend(abc.ABC):
|
|
27
|
+
def debug_mode(self) -> bool:
|
|
28
|
+
"""Should be overidded to return `True` in order to enable the debug mode."""
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
def check_url(self, url: str) -> None:
|
|
32
|
+
check_url(url, debug=self.debug_mode())
|
|
33
|
+
|
|
34
|
+
def user_agent(self) -> str:
|
|
35
|
+
return (
|
|
36
|
+
f"{requests.utils.default_user_agent()} (Active Boxes/{__version__};"
|
|
37
|
+
" +http://github.com/tsileo/little-boxes)"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def random_object_id(self) -> str:
|
|
41
|
+
"""Generates a random object ID."""
|
|
42
|
+
return binascii.hexlify(os.urandom(8)).decode("utf-8")
|
|
43
|
+
|
|
44
|
+
def fetch_json(self, url: str, **kwargs):
|
|
45
|
+
self.check_url(url)
|
|
46
|
+
resp = requests.get(
|
|
47
|
+
url,
|
|
48
|
+
headers={"User-Agent": self.user_agent(), "Accept": "application/json"},
|
|
49
|
+
**kwargs,
|
|
50
|
+
timeout=15,
|
|
51
|
+
allow_redirects=True,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
resp.raise_for_status()
|
|
55
|
+
|
|
56
|
+
return resp
|
|
57
|
+
|
|
58
|
+
def parse_collection(
|
|
59
|
+
self, payload: Optional[Dict[str, Any]] = None, url: Optional[str] = None
|
|
60
|
+
) -> List[str]:
|
|
61
|
+
return parse_collection(payload=payload, url=url, fetcher=self.fetch_iri)
|
|
62
|
+
|
|
63
|
+
def extra_inboxes(self) -> List[str]:
|
|
64
|
+
"""Allows to define inboxes that will be part of of the recipient for every activity."""
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
def is_from_outbox(
|
|
68
|
+
self, as_actor: "ap.Person", activity: "ap.BaseActivity"
|
|
69
|
+
) -> bool:
|
|
70
|
+
return activity.get_actor().id == as_actor.id
|
|
71
|
+
|
|
72
|
+
@abc.abstractmethod
|
|
73
|
+
def base_url(self) -> str:
|
|
74
|
+
pass # pragma: no cover
|
|
75
|
+
|
|
76
|
+
def fetch_iri(self, iri: str, **kwargs) -> "ap.ObjectType": # pragma: no cover
|
|
77
|
+
if not iri.startswith("http"):
|
|
78
|
+
raise NotAnActivityError(f"{iri} is not a valid IRI")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
self.check_url(iri)
|
|
82
|
+
except URLLookupFailedError:
|
|
83
|
+
# The IRI is inaccessible
|
|
84
|
+
raise ActivityUnavailableError(f"unable to fetch {iri}, url lookup failed")
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
resp = requests.get(
|
|
88
|
+
iri,
|
|
89
|
+
headers={
|
|
90
|
+
"User-Agent": self.user_agent(),
|
|
91
|
+
"Accept": "application/activity+json",
|
|
92
|
+
},
|
|
93
|
+
timeout=15,
|
|
94
|
+
allow_redirects=False,
|
|
95
|
+
**kwargs,
|
|
96
|
+
)
|
|
97
|
+
except (
|
|
98
|
+
requests.exceptions.ConnectTimeout,
|
|
99
|
+
requests.exceptions.ReadTimeout,
|
|
100
|
+
requests.exceptions.ConnectionError,
|
|
101
|
+
):
|
|
102
|
+
raise ActivityUnavailableError(f"unable to fetch {iri}, connection error")
|
|
103
|
+
if resp.status_code == 404:
|
|
104
|
+
raise ActivityNotFoundError(f"{iri} is not found")
|
|
105
|
+
elif resp.status_code == 410:
|
|
106
|
+
raise ActivityGoneError(f"{iri} is gone")
|
|
107
|
+
elif resp.status_code in [500, 502, 503]:
|
|
108
|
+
raise ActivityUnavailableError(
|
|
109
|
+
f"unable to fetch {iri}, server error ({resp.status_code})"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
resp.raise_for_status()
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
out = resp.json()
|
|
116
|
+
except (json.JSONDecodeError, ValueError):
|
|
117
|
+
# TODO(tsileo): a special error type?
|
|
118
|
+
raise NotAnActivityError(f"{iri} is not JSON")
|
|
119
|
+
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
@abc.abstractmethod
|
|
123
|
+
def activity_url(self, obj_id: str) -> str:
|
|
124
|
+
pass # pragma: no cover
|
|
125
|
+
|
|
126
|
+
@abc.abstractmethod
|
|
127
|
+
def note_url(self, obj_id: str) -> str:
|
|
128
|
+
pass # pragma: no cover
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Collection releated utils."""
|
|
2
|
+
from typing import Any
|
|
3
|
+
from typing import Callable
|
|
4
|
+
from typing import Dict
|
|
5
|
+
from typing import List
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .errors import RecursionLimitExceededError
|
|
9
|
+
from .errors import UnexpectedActivityTypeError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_collection( # noqa: C901
|
|
13
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
14
|
+
url: Optional[str] = None,
|
|
15
|
+
level: int = 0,
|
|
16
|
+
fetcher: Optional[Callable[[str], Dict[str, Any]]] = None,
|
|
17
|
+
) -> List[Any]:
|
|
18
|
+
"""Resolve/fetch a `Collection`/`OrderedCollection`."""
|
|
19
|
+
if not fetcher:
|
|
20
|
+
raise Exception("must provide a fetcher")
|
|
21
|
+
if level > 3:
|
|
22
|
+
raise RecursionLimitExceededError("recursion limit exceeded")
|
|
23
|
+
|
|
24
|
+
# Go through all the pages
|
|
25
|
+
out: List[Any] = []
|
|
26
|
+
if url:
|
|
27
|
+
payload = fetcher(url)
|
|
28
|
+
if not payload:
|
|
29
|
+
raise ValueError("must at least prove a payload or an URL")
|
|
30
|
+
|
|
31
|
+
if payload["type"] in ["Collection", "OrderedCollection"]:
|
|
32
|
+
if "orderedItems" in payload:
|
|
33
|
+
return payload["orderedItems"]
|
|
34
|
+
if "items" in payload:
|
|
35
|
+
return payload["items"]
|
|
36
|
+
if "first" in payload:
|
|
37
|
+
if isinstance(payload["first"], str):
|
|
38
|
+
out.extend(
|
|
39
|
+
parse_collection(
|
|
40
|
+
url=payload["first"], level=level + 1, fetcher=fetcher
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
else:
|
|
44
|
+
if "orderedItems" in payload["first"]:
|
|
45
|
+
out.extend(payload["first"]["orderedItems"])
|
|
46
|
+
if "items" in payload["first"]:
|
|
47
|
+
out.extend(payload["first"]["items"])
|
|
48
|
+
n = payload["first"].get("next")
|
|
49
|
+
if n:
|
|
50
|
+
out.extend(
|
|
51
|
+
parse_collection(url=n, level=level + 1, fetcher=fetcher)
|
|
52
|
+
)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
while payload:
|
|
56
|
+
if payload["type"] in ["CollectionPage", "OrderedCollectionPage"]:
|
|
57
|
+
if "orderedItems" in payload:
|
|
58
|
+
out.extend(payload["orderedItems"])
|
|
59
|
+
if "items" in payload:
|
|
60
|
+
out.extend(payload["items"])
|
|
61
|
+
n = payload.get("next")
|
|
62
|
+
if n is None:
|
|
63
|
+
break
|
|
64
|
+
payload = fetcher(n)
|
|
65
|
+
else:
|
|
66
|
+
raise UnexpectedActivityTypeError(
|
|
67
|
+
"unexpected activity type {}".format(payload["type"])
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return out
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from typing import Dict
|
|
2
|
+
from typing import List
|
|
3
|
+
from typing import Tuple
|
|
4
|
+
|
|
5
|
+
from markdown import markdown
|
|
6
|
+
|
|
7
|
+
import regex as re
|
|
8
|
+
|
|
9
|
+
from .activitypub import get_backend
|
|
10
|
+
from .webfinger import get_actor_url
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _set_attrs(attrs, new=False):
|
|
14
|
+
attrs[(None, "target")] = "_blank"
|
|
15
|
+
attrs[(None, "class")] = "external"
|
|
16
|
+
attrs[(None, "rel")] = "noopener"
|
|
17
|
+
attrs[(None, "title")] = attrs[(None, "href")]
|
|
18
|
+
return attrs
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
HASHTAG_REGEX = re.compile(r"(#[\d\w]+)")
|
|
22
|
+
MENTION_REGEX = re.compile(r"@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def hashtagify(content: str) -> Tuple[str, List[Dict[str, str]]]:
|
|
26
|
+
base_url = get_backend().base_url()
|
|
27
|
+
tags = []
|
|
28
|
+
hashtags = re.findall(HASHTAG_REGEX, content)
|
|
29
|
+
hashtags = list(set(hashtags)) # unique tags
|
|
30
|
+
hashtags.sort()
|
|
31
|
+
hashtags.reverse() # replace longest tag first
|
|
32
|
+
for hashtag in hashtags:
|
|
33
|
+
tag = hashtag[1:]
|
|
34
|
+
link = f'<a href="{base_url}/tags/{tag}" class="mention hashtag" rel="tag">#<span>{tag}</span></a>'
|
|
35
|
+
tags.append(dict(href=f"{base_url}/tags/{tag}", name=hashtag, type="Hashtag"))
|
|
36
|
+
content = content.replace(hashtag, link)
|
|
37
|
+
return content, tags
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def mentionify(
|
|
41
|
+
content: str, hide_domain: bool = False
|
|
42
|
+
) -> Tuple[str, List[Dict[str, str]]]:
|
|
43
|
+
tags = []
|
|
44
|
+
for mention in re.findall(MENTION_REGEX, content):
|
|
45
|
+
_, username, domain = mention.split("@")
|
|
46
|
+
actor_url = get_actor_url(mention)
|
|
47
|
+
if not actor_url:
|
|
48
|
+
# FIXME(tsileo): raise an error?
|
|
49
|
+
continue
|
|
50
|
+
p = get_backend().fetch_iri(actor_url)
|
|
51
|
+
tags.append(dict(type="Mention", href=p["id"], name=mention))
|
|
52
|
+
|
|
53
|
+
d = f"@{domain}"
|
|
54
|
+
if hide_domain:
|
|
55
|
+
d = ""
|
|
56
|
+
|
|
57
|
+
link = f'<span class="h-card"><a href="{p["url"]}" class="u-url mention">@<span>{username}</span>{d}</a></span>'
|
|
58
|
+
content = content.replace(mention, link)
|
|
59
|
+
return content, tags
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_markdown(content: str) -> Tuple[str, List[Dict[str, str]]]:
|
|
63
|
+
tags = []
|
|
64
|
+
content, hashtag_tags = hashtagify(content)
|
|
65
|
+
tags.extend(hashtag_tags)
|
|
66
|
+
content, mention_tags = mentionify(content)
|
|
67
|
+
tags.extend(mention_tags)
|
|
68
|
+
content = markdown(content, extensions=["mdx_linkify"])
|
|
69
|
+
return content, tags
|
active_boxes/errors.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Errors raised by this package."""
|
|
2
|
+
from typing import Any
|
|
3
|
+
from typing import Dict
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Error(Exception):
|
|
8
|
+
"""Base error for exceptions raised by this package."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DropActivityPreProcessError(Error):
|
|
12
|
+
"""Raised in `_pre_process_from_inbox` to notify that we don't want to save the message.
|
|
13
|
+
|
|
14
|
+
(like when receiving `Announce` with an OStatus link).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ServerError(Error):
|
|
19
|
+
"""HTTP-friendly base error, with a status code, a message and an optional payload."""
|
|
20
|
+
|
|
21
|
+
status_code = 400
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
message: str,
|
|
26
|
+
status_code: Optional[int] = None,
|
|
27
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
Exception.__init__(self)
|
|
30
|
+
self.message = message
|
|
31
|
+
if status_code is not None:
|
|
32
|
+
self.status_code = status_code
|
|
33
|
+
self.payload = payload
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
36
|
+
rv = dict(self.payload or {})
|
|
37
|
+
rv["message"] = self.message
|
|
38
|
+
return rv
|
|
39
|
+
|
|
40
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
41
|
+
return (
|
|
42
|
+
f"{self.__class__.__qualname__}({self.message!r}, "
|
|
43
|
+
f"payload={self.payload!r}, status_code={self.status_code})"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __str__(self) -> str: # pragma: no cover
|
|
47
|
+
return self.__repr__()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ActorBlockedError(ServerError):
|
|
51
|
+
"""Raised when an activity from a blocked actor is received."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NotFromOutboxError(ServerError):
|
|
55
|
+
"""Raised when an activity targets an object from the inbox when an object from the oubox was expected."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ActivityNotFoundError(ServerError):
|
|
59
|
+
"""Raised when an activity is not found."""
|
|
60
|
+
|
|
61
|
+
status_code = 404
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ActivityGoneError(ServerError):
|
|
65
|
+
"""Raised when trying to fetch a remote activity that was deleted."""
|
|
66
|
+
|
|
67
|
+
status_code = 410
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BadActivityError(ServerError):
|
|
71
|
+
"""Raised when an activity could not be parsed/initialized."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RecursionLimitExceededError(BadActivityError):
|
|
75
|
+
"""Raised when the recursion limit for fetching remote object was exceeded (likely a collection)."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class UnexpectedActivityTypeError(BadActivityError):
|
|
79
|
+
"""Raised when an another activty was expected."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ActivityUnavailableError(ServerError):
|
|
83
|
+
"""Raises when fetching a remote activity times out."""
|
|
84
|
+
|
|
85
|
+
status_code = 503
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class NotAnActivityError(ServerError):
|
|
89
|
+
"""Raised when no JSON can be decoded.
|
|
90
|
+
|
|
91
|
+
Most likely raised when stumbling upon a OStatus notice or failed lookup.
|
|
92
|
+
"""
|
active_boxes/httpsig.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Implements HTTP signature for Flask requests.
|
|
2
|
+
|
|
3
|
+
Mastodon instances won't accept requests that are not signed using this scheme.
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import logging
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
from typing import Dict
|
|
12
|
+
from typing import Optional
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
from Crypto.Hash import SHA256
|
|
16
|
+
from Crypto.Signature import PKCS1_v1_5
|
|
17
|
+
from requests.auth import AuthBase
|
|
18
|
+
|
|
19
|
+
from .activitypub import get_backend
|
|
20
|
+
from .activitypub import _has_type
|
|
21
|
+
from .errors import ActivityNotFoundError
|
|
22
|
+
from .errors import ActivityGoneError
|
|
23
|
+
from .key import Key
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _build_signed_string(
|
|
29
|
+
signed_headers: str, method: str, path: str, headers: Any, body_digest: str
|
|
30
|
+
) -> str:
|
|
31
|
+
out = []
|
|
32
|
+
for signed_header in signed_headers.split(" "):
|
|
33
|
+
if signed_header == "(request-target)":
|
|
34
|
+
out.append("(request-target): " + method.lower() + " " + path)
|
|
35
|
+
elif signed_header == "digest":
|
|
36
|
+
out.append("digest: " + body_digest)
|
|
37
|
+
else:
|
|
38
|
+
out.append(signed_header + ": " + headers[signed_header])
|
|
39
|
+
return "\n".join(out)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_sig_header(val: Optional[str]) -> Optional[Dict[str, str]]:
|
|
43
|
+
if not val:
|
|
44
|
+
return None
|
|
45
|
+
out = {}
|
|
46
|
+
for data in val.split(","):
|
|
47
|
+
k, v = data.split("=", 1)
|
|
48
|
+
out[k] = v[1 : len(v) - 1] # noqa: black conflict
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _verify_h(signed_string, signature, pubkey):
|
|
53
|
+
signer = PKCS1_v1_5.new(pubkey)
|
|
54
|
+
digest = SHA256.new()
|
|
55
|
+
digest.update(signed_string.encode("utf-8"))
|
|
56
|
+
return signer.verify(digest, signature)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _body_digest(body: str) -> str:
|
|
60
|
+
h = hashlib.new("sha256")
|
|
61
|
+
h.update(body) # type: ignore
|
|
62
|
+
return "SHA-256=" + base64.b64encode(h.digest()).decode("utf-8")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _get_public_key(key_id: str) -> Key:
|
|
66
|
+
actor = get_backend().fetch_iri(key_id)
|
|
67
|
+
if _has_type(actor["type"], "Key"):
|
|
68
|
+
# The Key is not embedded in the Person
|
|
69
|
+
k = Key(actor["owner"], actor["id"])
|
|
70
|
+
k.load_pub(actor["publicKeyPem"])
|
|
71
|
+
else:
|
|
72
|
+
k = Key(actor["id"], actor["publicKey"]["id"])
|
|
73
|
+
k.load_pub(actor["publicKey"]["publicKeyPem"])
|
|
74
|
+
|
|
75
|
+
# Ensure the right key was fetch
|
|
76
|
+
if key_id != k.key_id():
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"failed to fetch requested key {key_id}: got {actor['publicKey']['id']}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return k
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def verify_request(method: str, path: str, headers: Any, body: str) -> bool:
|
|
85
|
+
hsig = _parse_sig_header(headers.get("Signature"))
|
|
86
|
+
if not hsig:
|
|
87
|
+
logger.debug("no signature in header")
|
|
88
|
+
return False
|
|
89
|
+
logger.debug(f"hsig={hsig}")
|
|
90
|
+
signed_string = _build_signed_string(
|
|
91
|
+
hsig["headers"], method, path, headers, _body_digest(body)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
k = _get_public_key(hsig["keyId"])
|
|
96
|
+
except (ActivityGoneError, ActivityNotFoundError):
|
|
97
|
+
logger.debug("cannot get public key")
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
return _verify_h(signed_string, base64.b64decode(hsig["signature"]), k.pubkey)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class HTTPSigAuth(AuthBase):
|
|
104
|
+
"""Requests auth plugin for signing requests on the fly."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, key: Key) -> None:
|
|
107
|
+
self.key = key
|
|
108
|
+
|
|
109
|
+
def __call__(self, r):
|
|
110
|
+
logger.info(f"keyid={self.key.key_id()}")
|
|
111
|
+
host = urlparse(r.url).netloc
|
|
112
|
+
|
|
113
|
+
bh = hashlib.new("sha256")
|
|
114
|
+
body = r.body
|
|
115
|
+
try:
|
|
116
|
+
body = r.body.encode("utf-8")
|
|
117
|
+
except AttributeError:
|
|
118
|
+
pass
|
|
119
|
+
bh.update(body)
|
|
120
|
+
bodydigest = "SHA-256=" + base64.b64encode(bh.digest()).decode("utf-8")
|
|
121
|
+
|
|
122
|
+
date = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT")
|
|
123
|
+
|
|
124
|
+
r.headers.update({"Digest": bodydigest, "Date": date, "Host": host})
|
|
125
|
+
|
|
126
|
+
sigheaders = "(request-target) user-agent host date digest content-type"
|
|
127
|
+
|
|
128
|
+
to_be_signed = _build_signed_string(
|
|
129
|
+
sigheaders, r.method, r.path_url, r.headers, bodydigest
|
|
130
|
+
)
|
|
131
|
+
signer = PKCS1_v1_5.new(self.key.privkey)
|
|
132
|
+
digest = SHA256.new()
|
|
133
|
+
digest.update(to_be_signed.encode("utf-8"))
|
|
134
|
+
sig = base64.b64encode(signer.sign(digest))
|
|
135
|
+
sig = sig.decode("utf-8")
|
|
136
|
+
|
|
137
|
+
key_id = self.key.key_id()
|
|
138
|
+
headers = {
|
|
139
|
+
"Signature": f'keyId="{key_id}",algorithm="rsa-sha256",headers="{sigheaders}",signature="{sig}"'
|
|
140
|
+
}
|
|
141
|
+
logger.debug(f"signed request headers={headers}")
|
|
142
|
+
|
|
143
|
+
r.headers.update(headers)
|
|
144
|
+
|
|
145
|
+
return r
|
active_boxes/key.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from typing import Any
|
|
3
|
+
from typing import Dict
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from Crypto.PublicKey import RSA
|
|
7
|
+
from Crypto.Util import number
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Key(object):
|
|
11
|
+
DEFAULT_KEY_SIZE = 2048
|
|
12
|
+
|
|
13
|
+
def __init__(self, owner: str, id_: Optional[str] = None) -> None:
|
|
14
|
+
self.owner = owner
|
|
15
|
+
self.privkey_pem: Optional[str] = None
|
|
16
|
+
self.pubkey_pem: Optional[str] = None
|
|
17
|
+
self.privkey: Optional[RSA.RsaKey] = None
|
|
18
|
+
self.pubkey: Optional[RSA.RsaKey] = None
|
|
19
|
+
self.id_ = id_
|
|
20
|
+
|
|
21
|
+
def load_pub(self, pubkey_pem: str) -> None:
|
|
22
|
+
self.pubkey_pem = pubkey_pem
|
|
23
|
+
self.pubkey = RSA.importKey(pubkey_pem)
|
|
24
|
+
|
|
25
|
+
def load(self, privkey_pem: str) -> None:
|
|
26
|
+
self.privkey_pem = privkey_pem
|
|
27
|
+
self.privkey = RSA.importKey(self.privkey_pem)
|
|
28
|
+
self.pubkey_pem = self.privkey.publickey().exportKey("PEM").decode("utf-8")
|
|
29
|
+
|
|
30
|
+
def new(self) -> None:
|
|
31
|
+
k = RSA.generate(self.DEFAULT_KEY_SIZE)
|
|
32
|
+
self.privkey_pem = k.exportKey("PEM").decode("utf-8")
|
|
33
|
+
self.pubkey_pem = k.publickey().exportKey("PEM").decode("utf-8")
|
|
34
|
+
self.privkey = k
|
|
35
|
+
|
|
36
|
+
def key_id(self) -> str:
|
|
37
|
+
return self.id_ or f"{self.owner}#main-key"
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"id": self.key_id(),
|
|
42
|
+
"owner": self.owner,
|
|
43
|
+
"publicKeyPem": self.pubkey_pem,
|
|
44
|
+
"type": "Key",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_dict(cls, data):
|
|
49
|
+
try:
|
|
50
|
+
k = cls(data["owner"], data["id"])
|
|
51
|
+
k.load_pub(data["publicKeyPem"])
|
|
52
|
+
except KeyError:
|
|
53
|
+
raise ValueError(f"bad key data {data!r}")
|
|
54
|
+
return k
|
|
55
|
+
|
|
56
|
+
def to_magic_key(self) -> str:
|
|
57
|
+
mod = base64.urlsafe_b64encode(
|
|
58
|
+
number.long_to_bytes(self.privkey.n) # type: ignore
|
|
59
|
+
).decode("utf-8")
|
|
60
|
+
pubexp = base64.urlsafe_b64encode(
|
|
61
|
+
number.long_to_bytes(self.privkey.e) # type: ignore
|
|
62
|
+
).decode("utf-8")
|
|
63
|
+
return f"data:application/magic-public-key,RSA.{mod}.{pubexp}"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import typing
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
from typing import Dict
|
|
7
|
+
|
|
8
|
+
from Crypto.Hash import SHA256
|
|
9
|
+
from Crypto.Signature import PKCS1_v1_5
|
|
10
|
+
from pyld import jsonld
|
|
11
|
+
|
|
12
|
+
if typing.TYPE_CHECKING:
|
|
13
|
+
from .key import Key # noqa: type checking
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# cache the downloaded "schemas", otherwise the library is super slow
|
|
17
|
+
# (https://github.com/digitalbazaar/pyld/issues/70)
|
|
18
|
+
_CACHE: Dict[str, Any] = {}
|
|
19
|
+
LOADER = jsonld.requests_document_loader()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _caching_document_loader(url: str) -> Any:
|
|
23
|
+
if url in _CACHE:
|
|
24
|
+
return _CACHE[url]
|
|
25
|
+
resp = LOADER(url)
|
|
26
|
+
_CACHE[url] = resp
|
|
27
|
+
return resp
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
jsonld.set_document_loader(_caching_document_loader)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _options_hash(doc):
|
|
34
|
+
doc = dict(doc["signature"])
|
|
35
|
+
for k in ["type", "id", "signatureValue"]:
|
|
36
|
+
if k in doc:
|
|
37
|
+
del doc[k]
|
|
38
|
+
doc["@context"] = "https://w3id.org/identity/v1"
|
|
39
|
+
normalized = jsonld.normalize(
|
|
40
|
+
doc, {"algorithm": "URDNA2015", "format": "application/nquads"}
|
|
41
|
+
)
|
|
42
|
+
h = hashlib.new("sha256")
|
|
43
|
+
h.update(normalized.encode("utf-8"))
|
|
44
|
+
return h.hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _doc_hash(doc):
|
|
48
|
+
doc = dict(doc)
|
|
49
|
+
if "signature" in doc:
|
|
50
|
+
del doc["signature"]
|
|
51
|
+
normalized = jsonld.normalize(
|
|
52
|
+
doc, {"algorithm": "URDNA2015", "format": "application/nquads"}
|
|
53
|
+
)
|
|
54
|
+
h = hashlib.new("sha256")
|
|
55
|
+
h.update(normalized.encode("utf-8"))
|
|
56
|
+
return h.hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def verify_signature(doc, key: "Key"):
|
|
60
|
+
to_be_signed = _options_hash(doc) + _doc_hash(doc)
|
|
61
|
+
signature = doc["signature"]["signatureValue"]
|
|
62
|
+
signer = PKCS1_v1_5.new(key.pubkey or key.privkey) # type: ignore
|
|
63
|
+
digest = SHA256.new()
|
|
64
|
+
digest.update(to_be_signed.encode("utf-8"))
|
|
65
|
+
return signer.verify(digest, base64.b64decode(signature)) # type: ignore
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def generate_signature(doc, key: "Key"):
|
|
69
|
+
options = {
|
|
70
|
+
"type": "RsaSignature2017",
|
|
71
|
+
"creator": doc["actor"] + "#main-key",
|
|
72
|
+
"created": datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
|
|
73
|
+
}
|
|
74
|
+
doc["signature"] = options
|
|
75
|
+
to_be_signed = _options_hash(doc) + _doc_hash(doc)
|
|
76
|
+
if not key.privkey:
|
|
77
|
+
raise ValueError(f"missing privkey on key {key!r}")
|
|
78
|
+
|
|
79
|
+
signer = PKCS1_v1_5.new(key.privkey)
|
|
80
|
+
digest = SHA256.new()
|
|
81
|
+
digest.update(to_be_signed.encode("utf-8"))
|
|
82
|
+
sig = base64.b64encode(signer.sign(digest)) # type: ignore
|
|
83
|
+
options["signatureValue"] = sig.decode("utf-8")
|