summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAndrew Godwin2022-11-12 15:10:15 -0700
committerAndrew Godwin2022-11-12 15:10:15 -0700
commitdd4328ae523bb375dd871e85d1bacd9311e87a89 (patch)
tree6a4ec8bc83be3bdd18421b3f0c221b7a6091cf9e
parent8fd5a9292c7d3aac352d3c0e96288bff8a79cb47 (diff)
downloadtakahe-dd4328ae523bb375dd871e85d1bacd9311e87a89.tar.gz
takahe-dd4328ae523bb375dd871e85d1bacd9311e87a89.tar.bz2
takahe-dd4328ae523bb375dd871e85d1bacd9311e87a89.zip
Add JSON-LD signatures and tests for sig stuff
-rw-r--r--activities/models/fan_out.py3
-rw-r--r--core/ld.py85
-rw-r--r--core/signatures.py190
-rw-r--r--core/tests/conftest.py9
-rw-r--r--core/tests/test_signatures.py156
-rw-r--r--requirements.txt2
-rw-r--r--setup.cfg6
-rw-r--r--users/admin.py6
-rw-r--r--users/migrations/0002_identity_public_key_id.py18
-rw-r--r--users/models/follow.py9
-rw-r--r--users/models/identity.py36
-rw-r--r--users/views/identity.py71
12 files changed, 501 insertions, 90 deletions
diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py
index 79b7409..96e8df7 100644
--- a/activities/models/fan_out.py
+++ b/activities/models/fan_out.py
@@ -32,7 +32,8 @@ class FanOutStates(StateGraph):
await HttpSignature.signed_request(
uri=fan_out.identity.inbox_uri,
body=canonicalise(post.to_create_ap()),
- identity=post.author,
+ private_key=post.author.public_key,
+ key_id=post.author.public_key_id,
)
return cls.sent
diff --git a/core/ld.py b/core/ld.py
index 8ced9ac..7863480 100644
--- a/core/ld.py
+++ b/core/ld.py
@@ -229,6 +229,91 @@ schemas = {
}
},
},
+ "w3id.org/identity/v1": {
+ "contentType": "application/ld+json",
+ "documentUrl": "http://w3id.org/identity/v1",
+ "contextUrl": None,
+ "document": {
+ "@context": {
+ "id": "@id",
+ "type": "@type",
+ "cred": "https://w3id.org/credentials#",
+ "dc": "http://purl.org/dc/terms/",
+ "identity": "https://w3id.org/identity#",
+ "perm": "https://w3id.org/permissions#",
+ "ps": "https://w3id.org/payswarm#",
+ "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
+ "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
+ "sec": "https://w3id.org/security#",
+ "schema": "http://schema.org/",
+ "xsd": "http://www.w3.org/2001/XMLSchema#",
+ "Group": "https://www.w3.org/ns/activitystreams#Group",
+ "claim": {"@id": "cred:claim", "@type": "@id"},
+ "credential": {"@id": "cred:credential", "@type": "@id"},
+ "issued": {"@id": "cred:issued", "@type": "xsd:dateTime"},
+ "issuer": {"@id": "cred:issuer", "@type": "@id"},
+ "recipient": {"@id": "cred:recipient", "@type": "@id"},
+ "Credential": "cred:Credential",
+ "CryptographicKeyCredential": "cred:CryptographicKeyCredential",
+ "about": {"@id": "schema:about", "@type": "@id"},
+ "address": {"@id": "schema:address", "@type": "@id"},
+ "addressCountry": "schema:addressCountry",
+ "addressLocality": "schema:addressLocality",
+ "addressRegion": "schema:addressRegion",
+ "comment": "rdfs:comment",
+ "created": {"@id": "dc:created", "@type": "xsd:dateTime"},
+ "creator": {"@id": "dc:creator", "@type": "@id"},
+ "description": "schema:description",
+ "email": "schema:email",
+ "familyName": "schema:familyName",
+ "givenName": "schema:givenName",
+ "image": {"@id": "schema:image", "@type": "@id"},
+ "label": "rdfs:label",
+ "name": "schema:name",
+ "postalCode": "schema:postalCode",
+ "streetAddress": "schema:streetAddress",
+ "title": "dc:title",
+ "url": {"@id": "schema:url", "@type": "@id"},
+ "Person": "schema:Person",
+ "PostalAddress": "schema:PostalAddress",
+ "Organization": "schema:Organization",
+ "identityService": {"@id": "identity:identityService", "@type": "@id"},
+ "idp": {"@id": "identity:idp", "@type": "@id"},
+ "Identity": "identity:Identity",
+ "paymentProcessor": "ps:processor",
+ "preferences": {"@id": "ps:preferences", "@type": "@vocab"},
+ "cipherAlgorithm": "sec:cipherAlgorithm",
+ "cipherData": "sec:cipherData",
+ "cipherKey": "sec:cipherKey",
+ "digestAlgorithm": "sec:digestAlgorithm",
+ "digestValue": "sec:digestValue",
+ "domain": "sec:domain",
+ "expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"},
+ "initializationVector": "sec:initializationVector",
+ "member": {"@id": "schema:member", "@type": "@id"},
+ "memberOf": {"@id": "schema:memberOf", "@type": "@id"},
+ "nonce": "sec:nonce",
+ "normalizationAlgorithm": "sec:normalizationAlgorithm",
+ "owner": {"@id": "sec:owner", "@type": "@id"},
+ "password": "sec:password",
+ "privateKey": {"@id": "sec:privateKey", "@type": "@id"},
+ "privateKeyPem": "sec:privateKeyPem",
+ "publicKey": {"@id": "sec:publicKey", "@type": "@id"},
+ "publicKeyPem": "sec:publicKeyPem",
+ "publicKeyService": {"@id": "sec:publicKeyService", "@type": "@id"},
+ "revoked": {"@id": "sec:revoked", "@type": "xsd:dateTime"},
+ "signature": "sec:signature",
+ "signatureAlgorithm": "sec:signatureAlgorithm",
+ "signatureValue": "sec:signatureValue",
+ "CryptographicKey": "sec:Key",
+ "EncryptedMessage": "sec:EncryptedMessage",
+ "GraphSignature2012": "sec:GraphSignature2012",
+ "LinkedDataSignature2015": "sec:LinkedDataSignature2015",
+ "accessControl": {"@id": "perm:accessControl", "@type": "@id"},
+ "writePermission": {"@id": "perm:writePermission", "@type": "@id"},
+ }
+ },
+ },
"*/schemas/litepub-0.1.jsonld": {
"contentType": "application/ld+json",
"documentUrl": "http://w3id.org/security/v1",
diff --git a/core/signatures.py b/core/signatures.py
index 27e7f7d..74b5324 100644
--- a/core/signatures.py
+++ b/core/signatures.py
@@ -1,16 +1,33 @@
import base64
import json
-from typing import TYPE_CHECKING, Dict, List, Literal, TypedDict
+from typing import Dict, List, Literal, TypedDict
from urllib.parse import urlparse
import httpx
from cryptography.hazmat.primitives import hashes
from django.http import HttpRequest
-from django.utils.http import http_date
+from django.utils import timezone
+from django.utils.http import http_date, parse_http_date
+from OpenSSL import crypto
+from pyld import jsonld
-# Prevent a circular import
-if TYPE_CHECKING:
- from users.models import Identity
+from core.ld import format_date
+
+
+class VerificationError(BaseException):
+ """
+ There was an error with verifying the signature
+ """
+
+ pass
+
+
+class VerificationFormatError(VerificationError):
+ """
+ There was an error with the format of the signature (not if it is valid)
+ """
+
+ pass
class HttpSignature:
@@ -47,13 +64,13 @@ class HttpSignature:
return "\n".join(f"{name.lower()}: {value}" for name, value in headers.items())
@classmethod
- def parse_signature(cls, signature: str) -> "SignatureDetails":
+ def parse_signature(cls, signature: str) -> "HttpSignatureDetails":
bits = {}
for item in signature.split(","):
name, value = item.split("=", 1)
value = value.strip('"')
bits[name.lower()] = value
- signature_details: SignatureDetails = {
+ signature_details: HttpSignatureDetails = {
"headers": bits["headers"].split(),
"signature": base64.b64decode(bits["signature"]),
"algorithm": bits["algorithm"],
@@ -62,7 +79,7 @@ class HttpSignature:
return signature_details
@classmethod
- def compile_signature(cls, details: "SignatureDetails") -> str:
+ def compile_signature(cls, details: "HttpSignatureDetails") -> str:
value = f'keyId="{details["keyid"]}",headers="'
value += " ".join(h.lower() for h in details["headers"])
value += '",signature="'
@@ -71,11 +88,65 @@ class HttpSignature:
return value
@classmethod
+ def verify_signature(
+ cls,
+ signature: bytes,
+ cleartext: str,
+ public_key: str,
+ ):
+ x509 = crypto.X509()
+ x509.set_pubkey(
+ crypto.load_publickey(
+ crypto.FILETYPE_PEM,
+ public_key.encode("ascii"),
+ )
+ )
+ try:
+ crypto.verify(x509, signature, cleartext.encode("ascii"), "sha256")
+ except crypto.Error:
+ raise VerificationError("Signature mismatch")
+
+ @classmethod
+ def verify_request(cls, request, public_key, skip_date=False):
+ """
+ Verifies that the request has a valid signature for its body
+ """
+ # Verify body digest
+ if "HTTP_DIGEST" in request.META:
+ expected_digest = HttpSignature.calculate_digest(request.body)
+ if request.META["HTTP_DIGEST"] != expected_digest:
+ print("Wrong digest")
+ raise VerificationFormatError("Digest is incorrect")
+ # Verify date header
+ if "HTTP_DATE" in request.META and not skip_date:
+ header_date = parse_http_date(request.META["HTTP_DATE"])
+ if abs(timezone.now().timestamp() - header_date) > 60:
+ print(
+ f"Date mismatch - they sent {header_date}, now is {timezone.now().timestamp()}"
+ )
+ raise VerificationFormatError("Date is too far away")
+ # Get the signature details
+ if "HTTP_SIGNATURE" not in request.META:
+ raise VerificationFormatError("No signature header present")
+ signature_details = cls.parse_signature(request.META["HTTP_SIGNATURE"])
+ # Reject unknown algorithms
+ if signature_details["algorithm"] != "rsa-sha256":
+ raise VerificationFormatError("Unknown signature algorithm")
+ # Create the signature payload
+ headers_string = cls.headers_from_request(request, signature_details["headers"])
+ cls.verify_signature(
+ signature_details["signature"],
+ headers_string,
+ public_key,
+ )
+
+ @classmethod
async def signed_request(
self,
uri: str,
body: Dict,
- identity: "Identity",
+ private_key: str,
+ key_id: str,
content_type: str = "application/json",
method: Literal["post"] = "post",
):
@@ -96,11 +167,20 @@ class HttpSignature:
signed_string = "\n".join(
f"{name.lower()}: {value}" for name, value in headers.items()
)
+ pkey = crypto.load_privatekey(
+ crypto.FILETYPE_PEM,
+ private_key.encode("ascii"),
+ )
+ signature = crypto.sign(
+ pkey,
+ signed_string.encode("ascii"),
+ "sha256",
+ )
headers["Signature"] = self.compile_signature(
{
- "keyid": identity.key_id,
+ "keyid": key_id,
"headers": list(headers.keys()),
- "signature": identity.sign(signed_string),
+ "signature": signature,
"algorithm": "rsa-sha256",
}
)
@@ -120,8 +200,94 @@ class HttpSignature:
return response
-class SignatureDetails(TypedDict):
+class HttpSignatureDetails(TypedDict):
algorithm: str
headers: List[str]
signature: bytes
keyid: str
+
+
+class LDSignature:
+ """
+ Creates and verifies signatures of JSON-LD documents
+ """
+
+ @classmethod
+ def verify_signature(cls, document: Dict, public_key: str) -> None:
+ """
+ Verifies a document
+ """
+ try:
+ # Strip out the signature from the incoming document
+ signature = document.pop("signature")
+ # Create the options document
+ options = {
+ "@context": "https://w3id.org/identity/v1",
+ "creator": signature["creator"],
+ "created": signature["created"],
+ }
+ except KeyError:
+ raise VerificationFormatError("Invalid signature section")
+ if signature["type"].lower() != "rsasignature2017":
+ raise VerificationFormatError("Unknown signature type")
+ # Get the normalised hash of each document
+ final_hash = cls.normalized_hash(options) + cls.normalized_hash(document)
+ # Verify the signature
+ x509 = crypto.X509()
+ x509.set_pubkey(
+ crypto.load_publickey(
+ crypto.FILETYPE_PEM,
+ public_key.encode("ascii"),
+ )
+ )
+ try:
+ crypto.verify(
+ x509,
+ base64.b64decode(signature["signatureValue"]),
+ final_hash,
+ "sha256",
+ )
+ except crypto.Error:
+ raise VerificationError("Signature mismatch")
+
+ @classmethod
+ def create_signature(
+ cls, document: Dict, private_key: str, key_id: str
+ ) -> Dict[str, str]:
+ """
+ Creates the signature for a document
+ """
+ # Create the options document
+ options: Dict[str, str] = {
+ "@context": "https://w3id.org/identity/v1",
+ "creator": key_id,
+ "created": format_date(timezone.now()),
+ }
+ # Get the normalised hash of each document
+ final_hash = cls.normalized_hash(options) + cls.normalized_hash(document)
+ # Create the signature
+ pkey = crypto.load_privatekey(
+ crypto.FILETYPE_PEM,
+ private_key.encode("ascii"),
+ )
+ signature = base64.b64encode(crypto.sign(pkey, final_hash, "sha256"))
+ # Add it to the options document along with other bits
+ options["signatureValue"] = signature.decode("ascii")
+ options["type"] = "RsaSignature2017"
+ return options
+
+ @classmethod
+ def normalized_hash(cls, document) -> bytes:
+ """
+ Takes a JSON-LD document and create a hash of its URDNA2015 form,
+ in the same way that Mastodon does internally.
+
+ Reference: https://socialhub.activitypub.rocks/t/making-sense-of-rsasignature2017/347
+ """
+ norm_form = jsonld.normalize(
+ document,
+ {"algorithm": "URDNA2015", "format": "application/n-quads"},
+ )
+ digest = hashes.Hash(hashes.SHA256())
+ digest.update(norm_form.encode("utf8"))
+ return digest.finalize().hex().encode("ascii")
diff --git a/core/tests/conftest.py b/core/tests/conftest.py
new file mode 100644
index 0000000..ab8d6ea
--- /dev/null
+++ b/core/tests/conftest.py
@@ -0,0 +1,9 @@
+import pytest
+from pyld import jsonld
+
+from core.ld import builtin_document_loader
+
+
+@pytest.fixture(scope="session", autouse=True)
+def ldloader():
+ jsonld.set_document_loader(builtin_document_loader)
diff --git a/core/tests/test_signatures.py b/core/tests/test_signatures.py
new file mode 100644
index 0000000..2d480b7
--- /dev/null
+++ b/core/tests/test_signatures.py
@@ -0,0 +1,156 @@
+import pytest
+from asgiref.sync import async_to_sync
+from django.test.client import RequestFactory
+from pytest_httpx import HTTPXMock
+
+from core.signatures import HttpSignature, LDSignature, VerificationError
+
+# Our testing-only keypair
+private_key = """-----BEGIN PRIVATE KEY-----
+MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCzNJa9JIxQpOtQ
+z8UQKXDPREF9DyBliGu3uPWo6DMnkOm7hoh2+nOryrWDqWOFaVK//n7kltHXUEbm
+U3exh0/0iWfzx2AbNrI04csAvW/hRvHbHBnVTotSxzqTd3ESkpcSW4xVuz9aCcFR
+kW3unSCO3fF0Lh8Jsy9N/CT6oTnwG+ZpeGvHVbh9xfR5Ww6zA7z8A6B17hbzdMd/
+3qUPijyIb5se4cWVtGg/ZJ0X1syn9u9kpwUjhHlyWH/esMRHxPuW49BPZPhhKs1+
+t//4xgZcRX515qFqPS2EtYgZAfh7M3TRv8uCSzL4TT+8ka9IUwKdV6TFaqH27bAG
+KyJQfGaTAgMBAAECggEALZY5qFjlRtiFMfQApdlc5KTw4d7Yt2tqN3zaJUMYTD7d
+boJNMbMJfNCetyT+d6Aw2D1ly0GglNzLhGkEQElzKfpQUt/Lj3CtCa3Mpd4K2Wxi
+NwJhgfUulPqwaHYQchCPVLCsNNziw0VLA7Rymionb6B+/TaEV8PYy0ZSo90ir3UD
+CL5t+IWgIPiy6pk1wGOmeB+tU4+V7/hFel+vPFNahafqVhLE311dfx2aOfweAEfN
+e4JoPeJP1/fB+BVZMyVSAraKz6wheymBBNKKn/vpFsdd6it2AP4UZeFp6ma9wT9t
+nk65IpHg1MBxazQd7621GrPH+ZnhMg62H/FEj6rIDQKBgQC1w1fEbk+zjI54DXU8
+FAe5cJbZS89fMP5CtzlWKzTzfdaavT+5cUYp3XAv37tSGsqYAXxY+4bHGa+qdCQO
+I41cmylWGNX2e29/p2BspDPM6YQ0Z21MxFRBTWvHFrhd0bF1cXKBKPttdkKvzOEP
+6uNy+/QtRNn9xF/ZjaMHcyPPTQKBgQD8ZdOmZ3TMsYJchAjjseN8S+Objw2oZzmK
+6I1ULJBz3DWiyCUfir+pMjSH4fsAf9zrHkiM7xUgMByTukVRt16BrT7TlEBanAxc
+/AKdNB3f0pza829LCz1lMAUn+ngZLTmRR+1rQFXqTjhB+0peJzKiMli+9BBhL9Ry
+jMeTuLHdXwKBgGiz9kL5KIBNX2RYnEfXYfu4l6zktrgnCNB1q1mv2fjJbG4GxkaU
+sc47+Pwa7VUGid22PWMkwSa/7SlLbdmXMT8/QjiOZfJueHQYfrsWe6B2g+mMCrJG
+BiL37jXpKJsiyA7XIxaz/OG5VgDfDGaW8B60dJv/JXPBQ1WW+Wq5MM+hAoGAAUdS
+xykHAnJzwpw4n06rZFnOEV+sJgo/1GBRNvfy02NuMiDpbzt4tRa4BWgzqVD8gYRp
+wa0EYmFcA7OR3lQbenSyOMgre0oHFgGA0eMNs7CRctqA2dR4vyZ7IDS4nwgHnqDK
+pxxwUvuKdWsceVWhgAjZQj5iRtvDK8Fi0XDCFekCgYALTU1v5iMIpaRAe+eyA2B1
+42qm4B/uhXznvOu2YXU6iJFmMgHGYgpa+Dq8uUjKtpn/LIFeX1KN0hH8z/0LW3gB
+e7tN7taW0oLK3RQcEMfkZ7diE9x3LGqo/xMxsZMtxAr88p5eMEU/nxxznOqq+W9b
+qxRbXYzEtHz+cW9+FZkyVw==
+-----END PRIVATE KEY-----"""
+
+public_key = """-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAszSWvSSMUKTrUM/FEClw
+z0RBfQ8gZYhrt7j1qOgzJ5Dpu4aIdvpzq8q1g6ljhWlSv/5+5JbR11BG5lN3sYdP
+9Iln88dgGzayNOHLAL1v4Ubx2xwZ1U6LUsc6k3dxEpKXEluMVbs/WgnBUZFt7p0g
+jt3xdC4fCbMvTfwk+qE58BvmaXhrx1W4fcX0eVsOswO8/AOgde4W83THf96lD4o8
+iG+bHuHFlbRoP2SdF9bMp/bvZKcFI4R5clh/3rDER8T7luPQT2T4YSrNfrf/+MYG
+XEV+deahaj0thLWIGQH4ezN00b/Lgksy+E0/vJGvSFMCnVekxWqh9u2wBisiUHxm
+kwIDAQAB
+-----END PUBLIC KEY-----"""
+
+public_key_id = "https://example.com/test-actor#test-key"
+
+
+def test_sign_ld():
+ """
+ Tests signing JSON-LD documents by round-tripping them through the
+ verifier.
+ """
+ # Create the signature
+ document = {
+ "id": "https://example.com/test-create",
+ "type": "Create",
+ "actor": "https://example.com/test-actor",
+ "object": {
+ "id": "https://example.com/test-object",
+ "type": "Note",
+ },
+ }
+ signature_section = LDSignature.create_signature(
+ document,
+ private_key,
+ public_key_id,
+ )
+ # Check it and assign it to the document
+ assert "signatureValue" in signature_section
+ assert signature_section["type"] == "RsaSignature2017"
+ document["signature"] = signature_section
+ # Now verify it ourselves
+ LDSignature.verify_signature(document, public_key)
+
+
+def test_verifying_ld():
+ """
+ Tests verifying JSON-LD signatures from a known-good document
+ """
+ document = {
+ "id": "https://example.com/test-create",
+ "type": "Create",
+ "actor": "https://example.com/test-actor",
+ "object": {"id": "https://example.com/test-object", "type": "Note"},
+ "signature": {
+ "@context": "https://w3id.org/identity/v1",
+ "creator": "https://example.com/test-actor#test-key",
+ "created": "2022-11-12T21:41:47Z",
+ "signatureValue": "nTHfkHqG4hegfnjpHucXtXDLDaIKi2Duk+NeCzqTtkjf4NneXsofbZY2tGew4uAooEe1UeM23PIyjWYnR16KwcD4YY8nMj8L3xY2czwQPScMM9n+KhSHzkWfX+iI4FWKbjpPI8M53EtTRJU+1qEjjmGUx03Ip0vfvT5821etIgvY4wLNhg3y7R8fevnNux+BeytcEV6gM4awJJ6RK0xrWGLyTgDNon5V5aNUjwcV/UVPy9UAQi1KYWtA74/F0Y4oPzL5CTudPpyiViyVHZQaal4r+ExzgSvGztqKxQeT1ya6gLXxbm1YQ+8UiGVSS8zoGhMFDEZWVsRPv7e0jm5wfA==",
+ "type": "RsaSignature2017",
+ },
+ }
+ # Ensure it verifies with correct data
+ LDSignature.verify_signature(document, public_key)
+ # Mutate it slightly and ensure it does not verify
+ with pytest.raises(VerificationError):
+ document["actor"] = "https://example.com/evil-actor"
+ LDSignature.verify_signature(document, public_key)
+
+
+def test_sign_http(httpx_mock: HTTPXMock):
+ """
+ Tests signing HTTP requests by round-tripping them through our verifier
+ """
+ # Create document
+ document = {
+ "id": "https://example.com/test-create",
+ "type": "Create",
+ "actor": "https://example.com/test-actor",
+ "object": {
+ "id": "https://example.com/test-object",
+ "type": "Note",
+ },
+ }
+ # Send the signed request to the mock library
+ httpx_mock.add_response()
+ async_to_sync(HttpSignature.signed_request)(
+ uri="https://example.com/test-actor",
+ body=document,
+ private_key=private_key,
+ key_id=public_key_id,
+ )
+ # Retrieve it and construct a fake request object
+ outbound_request = httpx_mock.get_request()
+ fake_request = RequestFactory().post(
+ path="/test-actor",
+ data=outbound_request.content,
+ content_type=outbound_request.headers["content-type"],
+ HTTP_HOST="example.com",
+ HTTP_DATE=outbound_request.headers["date"],
+ HTTP_SIGNATURE=outbound_request.headers["signature"],
+ HTTP_DIGEST=outbound_request.headers["digest"],
+ )
+ # Verify that
+ HttpSignature.verify_request(fake_request, public_key)
+
+
+def test_verify_http():
+ """
+ Tests verifying HTTP requests against a known good example
+ """
+ # Make our predictable request
+ fake_request = RequestFactory().post(
+ path="/test-actor",
+ data=b'{"id": "https://example.com/test-create", "type": "Create", "actor": "https://example.com/test-actor", "object": {"id": "https://example.com/test-object", "type": "Note"}}',
+ content_type="application/json",
+ HTTP_HOST="example.com",
+ HTTP_DATE="Sat, 12 Nov 2022 21:57:18 GMT",
+ HTTP_SIGNATURE='keyId="https://example.com/test-actor#test-key",headers="(request-target) host date digest content-type",signature="IRduYoDJIh90mprjUgOIdxY1iaBWHs5ou9vsDlcmSekg6DXMZTiXjmZxbNIrnpEbNFu3wTcqz1nv9H97Gp7orbYMuHm6j2ecxsvzSr37T9jxBbt3Ov3xSfuYWwhv6PuTWNxHtUQWNuAIc3wHDAQt8Flnak/uHe7swoAq4uHq2kt18iMW6CEV9XA5ESFho2HSUgRaifoNxJlIWbHYPJiP0t9aktgGBkpQoZ8ulOj3Ew4RwC1lwk9kzWiLIjU4tSAie8RbIy2g0aUvA1tQh9Uge1by3o7+349SL5iooj+B6WSCEvvjEl52wo3xoEQmv0ptYuSPLUgB9tP8q7DoHEc8Dw==",algorithm="rsa-sha256"',
+ HTTP_DIGEST="SHA-256=07sIbQ3GlOHWMbFMNajtPNtmUQXXu20UuvrIYLlI3kc=",
+ )
+ # Verify that
+ HttpSignature.verify_request(fake_request, public_key, skip_date=True)
diff --git a/requirements.txt b/requirements.txt
index 4f7c763..dbe6fb0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,3 +10,5 @@ uvicorn~=0.19
gunicorn~=20.1.0
psycopg2~=2.9.5
bleach~=5.0.1
+pytest-django~=4.5.2
+pytest-httpx~=0.21
diff --git a/setup.cfg b/setup.cfg
index bd9cef4..f70f6f1 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -7,6 +7,12 @@ max-line-length = 119
profile = black
multi_line_output = 3
+[tool:pytest]
+addopts = --tb=short
+DJANGO_SETTINGS_MODULE = takahe.settings
+filterwarnings =
+ ignore:There is no current event loop
+
[mypy]
warn_unused_ignores = True
diff --git a/users/admin.py b/users/admin.py
index a4cea27..61a2bc8 100644
--- a/users/admin.py
+++ b/users/admin.py
@@ -22,6 +22,12 @@ class UserEventAdmin(admin.ModelAdmin):
class IdentityAdmin(admin.ModelAdmin):
list_display = ["id", "handle", "actor_uri", "state", "local"]
raw_id_fields = ["users"]
+ actions = ["force_update"]
+
+ @admin.action(description="Force Update")
+ def force_update(self, request, queryset):
+ for instance in queryset:
+ instance.transition_perform("outdated")
@admin.register(Follow)
diff --git a/users/migrations/0002_identity_public_key_id.py b/users/migrations/0002_identity_public_key_id.py
new file mode 100644
index 0000000..3648c20
--- /dev/null
+++ b/users/migrations/0002_identity_public_key_id.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.3 on 2022-11-12 21:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("users", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="identity",
+ name="public_key_id",
+ field=models.TextField(blank=True, null=True),
+ ),
+ ]
diff --git a/users/models/follow.py b/users/models/follow.py
index 81ffcd9..238081e 100644
--- a/users/models/follow.py
+++ b/users/models/follow.py
@@ -37,7 +37,8 @@ class FollowStates(StateGraph):
await HttpSignature.signed_request(
uri=follow.target.inbox_uri,
body=canonicalise(follow.to_ap()),
- identity=follow.source,
+ private_key=follow.source.public_key,
+ key_id=follow.source.public_key_id,
)
return cls.local_requested
@@ -56,7 +57,8 @@ class FollowStates(StateGraph):
await HttpSignature.signed_request(
uri=follow.source.inbox_uri,
body=canonicalise(follow.to_accept_ap()),
- identity=follow.target,
+ private_key=follow.target.public_key,
+ key_id=follow.target.public_key_id,
)
return cls.accepted
@@ -69,7 +71,8 @@ class FollowStates(StateGraph):
await HttpSignature.signed_request(
uri=follow.target.inbox_uri,
body=canonicalise(follow.to_undo_ap()),
- identity=follow.source,
+ private_key=follow.source.public_key,
+ key_id=follow.source.public_key_id,
)
return cls.undone_remotely
diff --git a/users/models/identity.py b/users/models/identity.py
index 4ec0342..96e09c8 100644
--- a/users/models/identity.py
+++ b/users/models/identity.py
@@ -11,7 +11,6 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from django.db import models
from django.utils import timezone
-from OpenSSL import crypto
from core.ld import canonicalise
from stator.models import State, StateField, StateGraph, StatorModel
@@ -89,6 +88,7 @@ class Identity(StatorModel):
private_key = models.TextField(null=True, blank=True)
public_key = models.TextField(null=True, blank=True)
+ public_key_id = models.TextField(null=True, blank=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
@@ -182,10 +182,6 @@ class Identity(StatorModel):
# TODO: Setting
return self.data_age > 60 * 24 * 24
- @property
- def key_id(self):
- return self.actor_uri + "#main-key"
-
### Actor/Webfinger fetching ###
@classmethod
@@ -242,6 +238,7 @@ class Identity(StatorModel):
"as:manuallyApprovesFollowers"
)
self.public_key = document.get("publicKey", {}).get("publicKeyPem")
+ self.public_key_id = document.get("publicKey", {}).get("id")
self.icon_uri = document.get("icon", {}).get("url")
self.image_uri = document.get("image", {}).get("url")
# Now go do webfinger with that info to see if we can get a canonical domain
@@ -286,32 +283,3 @@ class Identity(StatorModel):
.decode("ascii")
)
self.save()
-
- def sign(self, cleartext: str) -> bytes:
- if not self.private_key:
- raise ValueError("Cannot sign - no private key")
- pkey = crypto.load_privatekey(
- crypto.FILETYPE_PEM,
- self.private_key.encode("ascii"),
- )
- return crypto.sign(
- pkey,
- cleartext.encode("ascii"),
- "sha256",
- )
-
- def verify_signature(self, signature: bytes, cleartext: str) -> bool:
- if not self.public_key:
- raise ValueError("Cannot verify - no public key")
- x509 = crypto.X509()
- x509.set_pubkey(
- crypto.load_publickey(
- crypto.FILETYPE_PEM,
- self.public_key.encode("ascii"),
- )
- )
- try:
- crypto.verify(x509, signature, cleartext.encode("ascii"), "sha256")
- except crypto.Error:
- return False
- return True
diff --git a/users/views/identity.py b/users/views/identity.py
index 3bcf5c5..d4e1155 100644
--- a/users/views/identity.py
+++ b/users/views/identity.py
@@ -7,15 +7,18 @@ from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse
from django.shortcuts import redirect
-from django.utils import timezone
from django.utils.decorators import method_decorator
-from django.utils.http import parse_http_date
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import FormView, TemplateView, View
from core.forms import FormHelper
from core.ld import canonicalise
-from core.signatures import HttpSignature
+from core.signatures import (
+ HttpSignature,
+ LDSignature,
+ VerificationError,
+ VerificationFormatError,
+)
from users.decorators import identity_required
from users.models import Domain, Follow, Identity, IdentityStates, InboxMessage
from users.shortcuts import by_handle_or_404
@@ -167,7 +170,7 @@ class Actor(View):
"inbox": identity.actor_uri + "inbox/",
"preferredUsername": identity.username,
"publicKey": {
- "id": identity.key_id,
+ "id": identity.public_key_id,
"owner": identity.actor_uri,
"publicKeyPem": identity.public_key,
},
@@ -188,37 +191,8 @@ class Inbox(View):
"""
def post(self, request, handle):
- # Verify body digest
- if "HTTP_DIGEST" in request.META:
- expected_digest = HttpSignature.calculate_digest(request.body)
- if request.META["HTTP_DIGEST"] != expected_digest:
- print("Wrong digest")
- return HttpResponseBadRequest("Digest is incorrect")
- # Verify date header
- if "HTTP_DATE" in request.META:
- header_date = parse_http_date(request.META["HTTP_DATE"])
- if abs(timezone.now().timestamp() - header_date) > 60:
- print(
- f"Date mismatch - they sent {header_date}, now is {timezone.now().timestamp()}"
- )
- return HttpResponseBadRequest("Date is too far away")
- # Get the signature details
- if "HTTP_SIGNATURE" not in request.META:
- print("No signature")
- return HttpResponseBadRequest("No signature present")
- signature_details = HttpSignature.parse_signature(
- request.META["HTTP_SIGNATURE"]
- )
- # Reject unknown algorithms
- if signature_details["algorithm"] != "rsa-sha256":
- print("Unknown sig algo")
- return HttpResponseBadRequest("Unknown signature algorithm")
- # Create the signature payload
- headers_string = HttpSignature.headers_from_request(
- request, signature_details["headers"]
- )
# Load the LD
- document = canonicalise(json.loads(request.body))
+ document = canonicalise(json.loads(request.body), include_security=True)
# Find the Identity by the actor on the incoming item
# This ensures that the signature used for the headers matches the actor
# described in the payload.
@@ -229,12 +203,29 @@ class Inbox(View):
if not identity.public_key:
print("Cannot get actor")
return HttpResponseBadRequest("Cannot retrieve actor")
- if not identity.verify_signature(
- signature_details["signature"], headers_string
- ):
- print("Bad signature!")
- print(document)
- return HttpResponseUnauthorized("Bad signature")
+ # If there's a "signature" payload, verify against that
+ if "signature" in document:
+ try:
+ LDSignature.verify_signature(document, identity.public_key)
+ except VerificationFormatError as e:
+ print("Bad LD signature format:", e.args[0])
+ return HttpResponseBadRequest(e.args[0])
+ except VerificationError:
+ print("Bad LD signature")
+ return HttpResponseUnauthorized("Bad signature")
+ # Otherwise, verify against the header (assuming it's the same actor)
+ else:
+ try:
+ HttpSignature.verify_request(
+ request,
+ identity.public_key,
+ )
+ except VerificationFormatError as e:
+ print("Bad HTTP signature format:", e.args[0])
+ return HttpResponseBadRequest(e.args[0])
+ except VerificationError:
+ print("Bad HTTP signature")
+ return HttpResponseUnauthorized("Bad signature")
# Hand off the item to be processed by the queue
InboxMessage.objects.create(message=document)
return HttpResponse(status=202)