Squashed 'core/' content from commit 4957443
git-subtree-dir: core git-subtree-split: 4957443184ae0eb6323635a90a19acffb3e01d07
This commit is contained in:
163
artdag/activitypub/signatures.py
Normal file
163
artdag/activitypub/signatures.py
Normal file
@@ -0,0 +1,163 @@
|
||||
# primitive/activitypub/signatures.py
|
||||
"""
|
||||
Cryptographic signatures for ActivityPub.
|
||||
|
||||
Uses RSA-SHA256 signatures compatible with HTTP Signatures spec
|
||||
and Linked Data Signatures for ActivityPub.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
|
||||
from .actor import Actor
|
||||
from .activity import Activity
|
||||
|
||||
|
||||
def _canonicalize(data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Canonicalize JSON for signing.
|
||||
|
||||
Uses JCS (JSON Canonicalization Scheme) - sorted keys, no whitespace.
|
||||
"""
|
||||
return json.dumps(data, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _hash_sha256(data: str) -> bytes:
|
||||
"""Hash string with SHA-256."""
|
||||
return hashlib.sha256(data.encode()).digest()
|
||||
|
||||
|
||||
def sign_activity(activity: Activity, actor: Actor) -> Activity:
|
||||
"""
|
||||
Sign an activity with the actor's private key.
|
||||
|
||||
Uses Linked Data Signatures with RsaSignature2017.
|
||||
|
||||
Args:
|
||||
activity: The activity to sign
|
||||
actor: The actor whose key signs the activity
|
||||
|
||||
Returns:
|
||||
Activity with signature attached
|
||||
"""
|
||||
# Load private key
|
||||
private_key = serialization.load_pem_private_key(
|
||||
actor.private_key,
|
||||
password=None,
|
||||
)
|
||||
|
||||
# Create signature options
|
||||
created = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
# Canonicalize the activity (without signature)
|
||||
activity_data = activity.to_activitypub()
|
||||
activity_data.pop("signature", None)
|
||||
canonical = _canonicalize(activity_data)
|
||||
|
||||
# Create the data to sign: hash of options + hash of document
|
||||
options = {
|
||||
"@context": "https://w3id.org/security/v1",
|
||||
"type": "RsaSignature2017",
|
||||
"creator": actor.key_id,
|
||||
"created": created,
|
||||
}
|
||||
options_hash = _hash_sha256(_canonicalize(options))
|
||||
document_hash = _hash_sha256(canonical)
|
||||
to_sign = options_hash + document_hash
|
||||
|
||||
# Sign with RSA-SHA256
|
||||
signature_bytes = private_key.sign(
|
||||
to_sign,
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
signature_value = base64.b64encode(signature_bytes).decode("utf-8")
|
||||
|
||||
# Attach signature to activity
|
||||
activity.signature = {
|
||||
"type": "RsaSignature2017",
|
||||
"creator": actor.key_id,
|
||||
"created": created,
|
||||
"signatureValue": signature_value,
|
||||
}
|
||||
|
||||
return activity
|
||||
|
||||
|
||||
def verify_signature(activity: Activity, public_key_pem: bytes) -> bool:
|
||||
"""
|
||||
Verify an activity's signature.
|
||||
|
||||
Args:
|
||||
activity: The activity with signature
|
||||
public_key_pem: PEM-encoded public key
|
||||
|
||||
Returns:
|
||||
True if signature is valid
|
||||
"""
|
||||
if not activity.signature:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Load public key
|
||||
public_key = serialization.load_pem_public_key(public_key_pem)
|
||||
|
||||
# Reconstruct signature options
|
||||
options = {
|
||||
"@context": "https://w3id.org/security/v1",
|
||||
"type": activity.signature["type"],
|
||||
"creator": activity.signature["creator"],
|
||||
"created": activity.signature["created"],
|
||||
}
|
||||
|
||||
# Canonicalize activity without signature
|
||||
activity_data = activity.to_activitypub()
|
||||
activity_data.pop("signature", None)
|
||||
canonical = _canonicalize(activity_data)
|
||||
|
||||
# Recreate signed data
|
||||
options_hash = _hash_sha256(_canonicalize(options))
|
||||
document_hash = _hash_sha256(canonical)
|
||||
signed_data = options_hash + document_hash
|
||||
|
||||
# Decode and verify signature
|
||||
signature_bytes = base64.b64decode(activity.signature["signatureValue"])
|
||||
public_key.verify(
|
||||
signature_bytes,
|
||||
signed_data,
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return True
|
||||
|
||||
except (InvalidSignature, KeyError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def verify_activity_ownership(activity: Activity, actor: Actor) -> bool:
|
||||
"""
|
||||
Verify that an activity was signed by the claimed actor.
|
||||
|
||||
Args:
|
||||
activity: The activity to verify
|
||||
actor: The claimed actor
|
||||
|
||||
Returns:
|
||||
True if the activity was signed by this actor
|
||||
"""
|
||||
if not activity.signature:
|
||||
return False
|
||||
|
||||
# Check creator matches actor
|
||||
if activity.signature.get("creator") != actor.key_id:
|
||||
return False
|
||||
|
||||
# Verify signature
|
||||
return verify_signature(activity, actor.public_key)
|
||||
Reference in New Issue
Block a user