mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-26 09:11:00 +00:00
Add JSON-LD signatures and tests for sig stuff
This commit is contained in:
parent
8fd5a9292c
commit
dd4328ae52
12 changed files with 501 additions and 90 deletions
|
@ -32,7 +32,8 @@ class FanOutStates(StateGraph):
|
||||||
await HttpSignature.signed_request(
|
await HttpSignature.signed_request(
|
||||||
uri=fan_out.identity.inbox_uri,
|
uri=fan_out.identity.inbox_uri,
|
||||||
body=canonicalise(post.to_create_ap()),
|
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
|
return cls.sent
|
||||||
|
|
||||||
|
|
85
core/ld.py
85
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": {
|
"*/schemas/litepub-0.1.jsonld": {
|
||||||
"contentType": "application/ld+json",
|
"contentType": "application/ld+json",
|
||||||
"documentUrl": "http://w3id.org/security/v1",
|
"documentUrl": "http://w3id.org/security/v1",
|
||||||
|
|
|
@ -1,16 +1,33 @@
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
from typing import TYPE_CHECKING, Dict, List, Literal, TypedDict
|
from typing import Dict, List, Literal, TypedDict
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from cryptography.hazmat.primitives import hashes
|
from cryptography.hazmat.primitives import hashes
|
||||||
from django.http import HttpRequest
|
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
|
from core.ld import format_date
|
||||||
if TYPE_CHECKING:
|
|
||||||
from users.models import Identity
|
|
||||||
|
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:
|
class HttpSignature:
|
||||||
|
@ -47,13 +64,13 @@ class HttpSignature:
|
||||||
return "\n".join(f"{name.lower()}: {value}" for name, value in headers.items())
|
return "\n".join(f"{name.lower()}: {value}" for name, value in headers.items())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_signature(cls, signature: str) -> "SignatureDetails":
|
def parse_signature(cls, signature: str) -> "HttpSignatureDetails":
|
||||||
bits = {}
|
bits = {}
|
||||||
for item in signature.split(","):
|
for item in signature.split(","):
|
||||||
name, value = item.split("=", 1)
|
name, value = item.split("=", 1)
|
||||||
value = value.strip('"')
|
value = value.strip('"')
|
||||||
bits[name.lower()] = value
|
bits[name.lower()] = value
|
||||||
signature_details: SignatureDetails = {
|
signature_details: HttpSignatureDetails = {
|
||||||
"headers": bits["headers"].split(),
|
"headers": bits["headers"].split(),
|
||||||
"signature": base64.b64decode(bits["signature"]),
|
"signature": base64.b64decode(bits["signature"]),
|
||||||
"algorithm": bits["algorithm"],
|
"algorithm": bits["algorithm"],
|
||||||
|
@ -62,7 +79,7 @@ class HttpSignature:
|
||||||
return signature_details
|
return signature_details
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def compile_signature(cls, details: "SignatureDetails") -> str:
|
def compile_signature(cls, details: "HttpSignatureDetails") -> str:
|
||||||
value = f'keyId="{details["keyid"]}",headers="'
|
value = f'keyId="{details["keyid"]}",headers="'
|
||||||
value += " ".join(h.lower() for h in details["headers"])
|
value += " ".join(h.lower() for h in details["headers"])
|
||||||
value += '",signature="'
|
value += '",signature="'
|
||||||
|
@ -70,12 +87,66 @@ class HttpSignature:
|
||||||
value += f'",algorithm="{details["algorithm"]}"'
|
value += f'",algorithm="{details["algorithm"]}"'
|
||||||
return value
|
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
|
@classmethod
|
||||||
async def signed_request(
|
async def signed_request(
|
||||||
self,
|
self,
|
||||||
uri: str,
|
uri: str,
|
||||||
body: Dict,
|
body: Dict,
|
||||||
identity: "Identity",
|
private_key: str,
|
||||||
|
key_id: str,
|
||||||
content_type: str = "application/json",
|
content_type: str = "application/json",
|
||||||
method: Literal["post"] = "post",
|
method: Literal["post"] = "post",
|
||||||
):
|
):
|
||||||
|
@ -96,11 +167,20 @@ class HttpSignature:
|
||||||
signed_string = "\n".join(
|
signed_string = "\n".join(
|
||||||
f"{name.lower()}: {value}" for name, value in headers.items()
|
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(
|
headers["Signature"] = self.compile_signature(
|
||||||
{
|
{
|
||||||
"keyid": identity.key_id,
|
"keyid": key_id,
|
||||||
"headers": list(headers.keys()),
|
"headers": list(headers.keys()),
|
||||||
"signature": identity.sign(signed_string),
|
"signature": signature,
|
||||||
"algorithm": "rsa-sha256",
|
"algorithm": "rsa-sha256",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -120,8 +200,94 @@ class HttpSignature:
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
class SignatureDetails(TypedDict):
|
class HttpSignatureDetails(TypedDict):
|
||||||
algorithm: str
|
algorithm: str
|
||||||
headers: List[str]
|
headers: List[str]
|
||||||
signature: bytes
|
signature: bytes
|
||||||
keyid: str
|
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")
|
||||||
|
|
9
core/tests/conftest.py
Normal file
9
core/tests/conftest.py
Normal file
|
@ -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)
|
156
core/tests/test_signatures.py
Normal file
156
core/tests/test_signatures.py
Normal file
|
@ -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)
|
|
@ -10,3 +10,5 @@ uvicorn~=0.19
|
||||||
gunicorn~=20.1.0
|
gunicorn~=20.1.0
|
||||||
psycopg2~=2.9.5
|
psycopg2~=2.9.5
|
||||||
bleach~=5.0.1
|
bleach~=5.0.1
|
||||||
|
pytest-django~=4.5.2
|
||||||
|
pytest-httpx~=0.21
|
||||||
|
|
|
@ -7,6 +7,12 @@ max-line-length = 119
|
||||||
profile = black
|
profile = black
|
||||||
multi_line_output = 3
|
multi_line_output = 3
|
||||||
|
|
||||||
|
[tool:pytest]
|
||||||
|
addopts = --tb=short
|
||||||
|
DJANGO_SETTINGS_MODULE = takahe.settings
|
||||||
|
filterwarnings =
|
||||||
|
ignore:There is no current event loop
|
||||||
|
|
||||||
[mypy]
|
[mypy]
|
||||||
warn_unused_ignores = True
|
warn_unused_ignores = True
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,12 @@ class UserEventAdmin(admin.ModelAdmin):
|
||||||
class IdentityAdmin(admin.ModelAdmin):
|
class IdentityAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "handle", "actor_uri", "state", "local"]
|
list_display = ["id", "handle", "actor_uri", "state", "local"]
|
||||||
raw_id_fields = ["users"]
|
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)
|
@admin.register(Follow)
|
||||||
|
|
18
users/migrations/0002_identity_public_key_id.py
Normal file
18
users/migrations/0002_identity_public_key_id.py
Normal file
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -37,7 +37,8 @@ class FollowStates(StateGraph):
|
||||||
await HttpSignature.signed_request(
|
await HttpSignature.signed_request(
|
||||||
uri=follow.target.inbox_uri,
|
uri=follow.target.inbox_uri,
|
||||||
body=canonicalise(follow.to_ap()),
|
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
|
return cls.local_requested
|
||||||
|
|
||||||
|
@ -56,7 +57,8 @@ class FollowStates(StateGraph):
|
||||||
await HttpSignature.signed_request(
|
await HttpSignature.signed_request(
|
||||||
uri=follow.source.inbox_uri,
|
uri=follow.source.inbox_uri,
|
||||||
body=canonicalise(follow.to_accept_ap()),
|
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
|
return cls.accepted
|
||||||
|
|
||||||
|
@ -69,7 +71,8 @@ class FollowStates(StateGraph):
|
||||||
await HttpSignature.signed_request(
|
await HttpSignature.signed_request(
|
||||||
uri=follow.target.inbox_uri,
|
uri=follow.target.inbox_uri,
|
||||||
body=canonicalise(follow.to_undo_ap()),
|
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
|
return cls.undone_remotely
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,6 @@ from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from OpenSSL import crypto
|
|
||||||
|
|
||||||
from core.ld import canonicalise
|
from core.ld import canonicalise
|
||||||
from stator.models import State, StateField, StateGraph, StatorModel
|
from stator.models import State, StateField, StateGraph, StatorModel
|
||||||
|
@ -89,6 +88,7 @@ class Identity(StatorModel):
|
||||||
|
|
||||||
private_key = models.TextField(null=True, blank=True)
|
private_key = models.TextField(null=True, blank=True)
|
||||||
public_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)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
updated = models.DateTimeField(auto_now=True)
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
@ -182,10 +182,6 @@ class Identity(StatorModel):
|
||||||
# TODO: Setting
|
# TODO: Setting
|
||||||
return self.data_age > 60 * 24 * 24
|
return self.data_age > 60 * 24 * 24
|
||||||
|
|
||||||
@property
|
|
||||||
def key_id(self):
|
|
||||||
return self.actor_uri + "#main-key"
|
|
||||||
|
|
||||||
### Actor/Webfinger fetching ###
|
### Actor/Webfinger fetching ###
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -242,6 +238,7 @@ class Identity(StatorModel):
|
||||||
"as:manuallyApprovesFollowers"
|
"as:manuallyApprovesFollowers"
|
||||||
)
|
)
|
||||||
self.public_key = document.get("publicKey", {}).get("publicKeyPem")
|
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.icon_uri = document.get("icon", {}).get("url")
|
||||||
self.image_uri = document.get("image", {}).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
|
# 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")
|
.decode("ascii")
|
||||||
)
|
)
|
||||||
self.save()
|
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
|
|
||||||
|
|
|
@ -7,15 +7,18 @@ from django.conf import settings
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse
|
from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.utils import timezone
|
|
||||||
from django.utils.decorators import method_decorator
|
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.decorators.csrf import csrf_exempt
|
||||||
from django.views.generic import FormView, TemplateView, View
|
from django.views.generic import FormView, TemplateView, View
|
||||||
|
|
||||||
from core.forms import FormHelper
|
from core.forms import FormHelper
|
||||||
from core.ld import canonicalise
|
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.decorators import identity_required
|
||||||
from users.models import Domain, Follow, Identity, IdentityStates, InboxMessage
|
from users.models import Domain, Follow, Identity, IdentityStates, InboxMessage
|
||||||
from users.shortcuts import by_handle_or_404
|
from users.shortcuts import by_handle_or_404
|
||||||
|
@ -167,7 +170,7 @@ class Actor(View):
|
||||||
"inbox": identity.actor_uri + "inbox/",
|
"inbox": identity.actor_uri + "inbox/",
|
||||||
"preferredUsername": identity.username,
|
"preferredUsername": identity.username,
|
||||||
"publicKey": {
|
"publicKey": {
|
||||||
"id": identity.key_id,
|
"id": identity.public_key_id,
|
||||||
"owner": identity.actor_uri,
|
"owner": identity.actor_uri,
|
||||||
"publicKeyPem": identity.public_key,
|
"publicKeyPem": identity.public_key,
|
||||||
},
|
},
|
||||||
|
@ -188,37 +191,8 @@ class Inbox(View):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def post(self, request, handle):
|
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
|
# 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
|
# Find the Identity by the actor on the incoming item
|
||||||
# This ensures that the signature used for the headers matches the actor
|
# This ensures that the signature used for the headers matches the actor
|
||||||
# described in the payload.
|
# described in the payload.
|
||||||
|
@ -229,11 +203,28 @@ class Inbox(View):
|
||||||
if not identity.public_key:
|
if not identity.public_key:
|
||||||
print("Cannot get actor")
|
print("Cannot get actor")
|
||||||
return HttpResponseBadRequest("Cannot retrieve actor")
|
return HttpResponseBadRequest("Cannot retrieve actor")
|
||||||
if not identity.verify_signature(
|
# If there's a "signature" payload, verify against that
|
||||||
signature_details["signature"], headers_string
|
if "signature" in document:
|
||||||
):
|
try:
|
||||||
print("Bad signature!")
|
LDSignature.verify_signature(document, identity.public_key)
|
||||||
print(document)
|
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")
|
return HttpResponseUnauthorized("Bad signature")
|
||||||
# Hand off the item to be processed by the queue
|
# Hand off the item to be processed by the queue
|
||||||
InboxMessage.objects.create(message=document)
|
InboxMessage.objects.create(message=document)
|
||||||
|
|
Loading…
Reference in a new issue