Created
June 14, 2026 14:04
-
-
Save lastforkbender/5da93f4622e38915f631c82ab43138f9 to your computer and use it in GitHub Desktop.
Extract, frame, canonicalize, sign, encrypt filename, wrap keys, emit turtle
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| ••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••• | |
| ••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••• | |
| RDF_TYX_GENEITY.PY | |
| >>> Extract, frame, canonicalize, sign, encrypt filename, wrap keys, emit turtle | |
| *Comprehensive logging, signature registry, js library inventory, verifiable data tracking | |
| ______________________________________________________________________________________________ | |
| -- NOTICE -- | |
| In advent of changes to this software, improper uses or other unexpected expectations. Thereof | |
| herein no conditions are liable of that extent and deemed nonproprietary to public use further. | |
| These no conditions not liable; remain intact regardless of any agencies demands. This software | |
| isn't of any origins to incorporate any legislation effective to proprietary system protection. | |
| The concepts & representing reach of this software intends criminal pattern analysis. Pertains | |
| to any incompatible or compatible instances of use therein. You may freely distribute it's | |
| associated coding yet there is absolutely no guarantee of any conditions clarifying proper | |
| defined uses. These terms and otherwise conditions relevant or not per usage fully understood. | |
| ••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••• | |
| ••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••• | |
| """ | |
| import os, json, base64, hashlib, argparse, logging, binascii | |
| from pathlib import Path | |
| from datetime import datetime | |
| from typing import Dict, List, Tuple, Optional, Any | |
| import requests | |
| from w3lib.html import get_base_url | |
| import extruct | |
| from bs4 import BeautifulSoup | |
| from rdflib import Graph, URIRef, Literal, Namespace | |
| from rdflib.namespace import RDF, XSD | |
| from pyld import jsonld | |
| from cryptography.hazmat.primitives.ciphers.aead import AESGCM | |
| from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey | |
| from cryptography.hazmat.primitives.asymmetric import rsa, padding | |
| from cryptography.hazmat.primitives import serialization, hashes | |
| from cryptography.hazmat.backends import default_backend | |
| import cryptography.exceptions | |
| def setup_logging(log_dir: Path = Path("./logs")): | |
| # >>> Initialize multi-channel logging for application, signatures, and libraries | |
| log_dir.mkdir(exist_ok=True) | |
| timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") | |
| app_log_file = log_dir / f"rdf_signal_{timestamp}.log" | |
| sig_log_file = log_dir / f"signature_registry_{timestamp}.json" | |
| lib_log_file = log_dir / f"js_library_inventory_{timestamp}.json" | |
| logging.basicConfig( | |
| level=logging.DEBUG, | |
| format='%(asctime)s [%(levelname)s] %(name)s: %(message)s', | |
| handlers=[ | |
| logging.FileHandler(app_log_file), | |
| logging.StreamHandler() ] ) | |
| logger_instance = logging.getLogger(__name__) | |
| logger_instance.info(f"Logging initialized. Log dir: {log_dir}") | |
| return app_log_file, sig_log_file, lib_log_file | |
| app_log_file, sig_log_file, lib_log_file = setup_logging() | |
| logger = logging.getLogger(__name__) | |
| # >>> Configuration <<< | |
| KEY_DIR = Path("./keys") | |
| KEY_DIR.mkdir(exist_ok=True) | |
| PRIVATE_KEY_FILE = KEY_DIR / "ed25519_private.pem" | |
| PUBLIC_KEY_FILE = KEY_DIR / "ed25519_public.pem" | |
| PRIVATE_KEY_PASSPHRASE_ENV = "RDF_KEY_PW" | |
| EX = Namespace("https://example.org/vocab#") | |
| DCT = Namespace("http://purl.org/dc/terms/") | |
| PROV = Namespace("http://www.w3.org/ns/prov#") | |
| class SignatureRegistry: | |
| # >>> Tracks all cryptographic signatures with full provenance and verification metadata | |
| def __init__(self, log_file: Path): | |
| self.log_file = log_file | |
| self.entries: List[Dict] = [] | |
| self.sig_counter = 0 | |
| self.verification_map: Dict[str, Dict] = {} | |
| def generate_static_id(self, prefix: str = "SIG") -> str: | |
| # >>> Generate deterministic, static signature identifiers | |
| self.sig_counter += 1 | |
| date_str = datetime.utcnow().strftime('%Y%m%d') | |
| return f"{prefix}-{date_str}-{self.sig_counter:08d}" | |
| def log_signature_creation(self, | |
| data_hash: str, | |
| algorithm: str, | |
| key_id: str, | |
| signature_b64: str, | |
| data_preview: str, | |
| data_size_bytes: int, | |
| data_type: str = "nquads") -> str: | |
| # >>> Log signature creation with full context | |
| sig_id = self.generate_static_id("SIG") | |
| entry = { | |
| "timestamp": datetime.utcnow().isoformat() + "Z", | |
| "event": "signature_created", | |
| "signature_id": sig_id, | |
| "algorithm": algorithm, | |
| "key_id": key_id, | |
| "data": { | |
| "hash": data_hash, | |
| "type": data_type, | |
| "preview": data_preview[:300], | |
| "size_bytes": data_size_bytes}, | |
| "signature_b64": signature_b64, | |
| "cryptographic_context": { | |
| "algorithm_family": "EdDSA", | |
| "curve": "Ed25519", | |
| "format": "LD-SIG:v1:alg=Ed25519;sig=<b64>", | |
| "encoding": "base64" }} | |
| self.entries.append(entry) | |
| self.verification_map[sig_id] = {"created": entry, "verifications": []} | |
| logger.info(f"• Signature created: {sig_id}") | |
| return sig_id | |
| def log_signature_verification(self, sig_id: str, | |
| result: bool, | |
| key_id: str = None, | |
| error: str = None) -> None: | |
| # >>> Log signature verification attempt | |
| if sig_id not in self.verification_map: | |
| logger.warning(f"Verification logged for unknown signature: {sig_id}") | |
| self.verification_map[sig_id] = {"created": None, "verifications": []} | |
| verification = { | |
| "timestamp": datetime.utcnow().isoformat() + "Z", | |
| "signature_id": sig_id, | |
| "result": "VALID" if result else "INVALID", | |
| "key_id": key_id, | |
| "error": error} | |
| self.verification_map[sig_id]["verifications"].append(verification) | |
| status_icon = "✓" if result else "✗" | |
| logger.info(f"{status_icon} Signature verification: {sig_id} = {verification['result']}") | |
| def flush_to_file(self) -> None: | |
| # >>> Write signature registry to JSON file | |
| output = { | |
| "generated_at": datetime.utcnow().isoformat() + "Z", | |
| "total_signatures": len(self.entries), | |
| "signatures": self.entries, | |
| "verification_summary": { | |
| sig_id: { | |
| "created": data["created"]["timestamp"], | |
| "verifications_count": len(data["verifications"]), | |
| "all_valid": all(v["result"] == "VALID" for v in data["verifications"]) if data["verifications"] else None, | |
| "verification_history": data["verifications"]} | |
| for sig_id, data in self.verification_map.items()}} | |
| try: | |
| self.log_file.write_text(json.dumps(output, indent=2)) | |
| logger.info(f"• Signature registry flushed to {self.log_file}") | |
| except Exception as e: | |
| logger.error(f"Failed to write signature registry: {e}") | |
| class JSLibraryInventory: | |
| # >>> Tracks js libraries found in html, their versions and integrity attributes | |
| def __init__(self, log_file: Path): | |
| self.log_file = log_file | |
| self.libraries: List[Dict] = [] | |
| self.sri_hashes: Dict[str, str] = {} | |
| def add_script(self, src: str, | |
| version: Optional[str] = None, | |
| integrity_hash: Optional[str] = None, | |
| sri_algorithm: Optional[str] = None, | |
| crossorigin: Optional[str] = None, | |
| context_url: str = None) -> None: | |
| # >>> Register a discovered script/library | |
| entry = { | |
| "timestamp": datetime.utcnow().isoformat() + "Z", | |
| "src": src, | |
| "version": version, | |
| "integrity": { | |
| "hash": integrity_hash, | |
| "algorithm": sri_algorithm | |
| } if integrity_hash else None, | |
| "crossorigin": crossorigin, | |
| "discovered_at": context_url} | |
| self.libraries.append(entry) | |
| if integrity_hash: | |
| self.sri_hashes[src] = integrity_hash | |
| logger.debug(f"JS Library registered: {src} v{version or 'unknown'}") | |
| def extract_from_html(self, html: str, base_url: str) -> None: | |
| # >>> Parse HTML and extract all script tags | |
| soup = BeautifulSoup(html, 'html.parser') | |
| scripts = soup.find_all('script', src=True) | |
| for script in scripts: | |
| src = script.get('src', '').strip() | |
| if not src: | |
| continue | |
| # >>> Resolve relative URLs | |
| full_src = requests.compat.urljoin(base_url, src) | |
| integrity = script.get('integrity', None) | |
| crossorigin = script.get('crossorigin', None) | |
| # >>> Try to extract version from URL | |
| version = None | |
| for pattern in [r'@(\d+\.\d+\.\d+)', r'/(\d+\.\d+\.\d+)/', r'v(\d+\.\d+\.\d+)']: | |
| import re | |
| match = re.search(pattern, src) | |
| if match: | |
| version = match.group(1) | |
| break | |
| # >>> Determine SRI algorithm if present | |
| sri_algo = None | |
| if integrity: | |
| sri_algo = integrity.split('-')[0] if '-' in integrity else "sha256" | |
| self.add_script(full_src, version=version, integrity_hash=integrity, | |
| sri_algorithm=sri_algo, crossorigin=crossorigin, | |
| context_url=base_url) | |
| def flush_to_file(self) -> None: | |
| # >>> Write library inventory to JSON file | |
| output = { | |
| "generated_at": datetime.utcnow().isoformat() + "Z", | |
| "total_libraries": len(self.libraries), | |
| "libraries": self.libraries, | |
| "sri_coverage": { | |
| "with_sri": len([l for l in self.libraries if l["integrity"]]), | |
| "without_sri": len([l for l in self.libraries if not l["integrity"]]), | |
| "sri_hashes": self.sri_hashes}} | |
| try: | |
| self.log_file.write_text(json.dumps(output, indent=2)) | |
| logger.info(f"• JS Library inventory flushed to {self.log_file}") | |
| except Exception as e: | |
| logger.error(f"Failed to write library inventory: {e}") | |
| class VerifiableDataTracker: | |
| # >>> Tracks all data inputs and outputs with hashes and provenance | |
| def __init__(self): | |
| self.data_map: Dict[str, Dict] = {} | |
| self.static_id_counter = 0 | |
| def generate_data_id(self, prefix: str = "DATA") -> str: | |
| # >>> Generate static data identifiers | |
| self.static_id_counter += 1 | |
| date_str = datetime.utcnow().strftime('%Y%m%d') | |
| return f"{prefix}-{date_str}-{self.static_id_counter:08d}" | |
| def track_data(self, data: Any, | |
| data_type: str, | |
| source: str, | |
| processing_stage: str) -> str: | |
| # >>> Register data with provenance tracking | |
| data_id = self.generate_data_id("DATA") | |
| # >>> Compute hash based on data type | |
| if isinstance(data, str): | |
| data_bytes = data.encode('utf-8') | |
| elif isinstance(data, bytes): | |
| data_bytes = data | |
| elif isinstance(data, dict): | |
| data_bytes = json.dumps(data, sort_keys=True).encode('utf-8') | |
| else: | |
| data_bytes = str(data).encode('utf-8') | |
| data_hash = "sha256:" + hashlib.sha256(data_bytes).hexdigest() | |
| entry = { | |
| "data_id": data_id, | |
| "timestamp": datetime.utcnow().isoformat() + "Z", | |
| "type": data_type, | |
| "source": source, | |
| "processing_stage": processing_stage, | |
| "hash": data_hash, | |
| "size_bytes": len(data_bytes), | |
| "preview": str(data)[:500] if not isinstance(data, (dict, list)) else "object"} | |
| self.data_map[data_id] = entry | |
| logger.debug(f"Data tracked: {data_id} ({data_type})") | |
| return data_id | |
| def ensure_ed25519_keypair(passphrase: bytes = None) -> Tuple[Ed25519PrivateKey, Ed25519PublicKey]: | |
| # >>> Generate or load Ed25519 keypair with enhanced error handling | |
| if PRIVATE_KEY_FILE.exists() and PUBLIC_KEY_FILE.exists(): | |
| try: | |
| with open(PRIVATE_KEY_FILE, "rb") as f: | |
| pem = f.read() | |
| priv = serialization.load_pem_private_key(pem, password=passphrase, backend=default_backend()) | |
| with open(PUBLIC_KEY_FILE, "rb") as f: | |
| pub = serialization.load_pem_public_key(f.read(), backend=default_backend()) | |
| logger.info(f"• Loaded existing Ed25519 keypair from {KEY_DIR}") | |
| return priv, pub | |
| except ValueError as e: | |
| logger.error(f"Failed to load private key: passphrase mismatch or corrupted key: {e}") | |
| raise ValueError(f"Key loading failed: {e}") | |
| except Exception as e: | |
| logger.error(f"Unexpected error loading keys: {e}") | |
| raise | |
| logger.info("Generating new Ed25519 keypair...") | |
| priv = Ed25519PrivateKey.generate() | |
| pub = priv.public_key() | |
| enc = serialization.BestAvailableEncryption(passphrase) if passphrase else serialization.NoEncryption() | |
| priv_pem = priv.private_bytes(encoding=serialization.Encoding.PEM, | |
| format=serialization.PrivateFormat.PKCS8, | |
| encryption_algorithm=enc) | |
| pub_pem = pub.public_bytes(encoding=serialization.Encoding.PEM, | |
| format=serialization.PublicFormat.SubjectPublicKeyInfo) | |
| PRIVATE_KEY_FILE.write_bytes(priv_pem) | |
| PUBLIC_KEY_FILE.write_bytes(pub_pem) | |
| logger.info(f"• Generated and saved Ed25519 keypair to {KEY_DIR}") | |
| return priv, pub | |
| def load_rsa_pub_pem(path: str) -> bytes: | |
| # >>> Load and validate RSA public key PEM | |
| try: | |
| pem_data = Path(path).read_bytes() | |
| # >>> Validate it's a valid RSA public key | |
| pub = serialization.load_pem_public_key(pem_data, backend=default_backend()) | |
| if not isinstance(pub, rsa.RSAPublicKey): | |
| raise ValueError("Public key is not RSA format") | |
| logger.debug(f"• Valid RSA public key loaded from {path}") | |
| return pem_data | |
| except FileNotFoundError: | |
| logger.error(f"RSA public key file not found: {path}") | |
| raise | |
| except Exception as e: | |
| logger.error(f"Invalid RSA public key PEM at {path}: {e}") | |
| raise ValueError(f"Invalid RSA public key: {e}") | |
| def load_rsa_priv_pem(path: str, passphrase: bytes = None) -> Tuple[bytes, Optional[bytes]]: | |
| # >>> Load and validate RSA private key PEM | |
| try: | |
| pem_data = Path(path).read_bytes() | |
| priv = serialization.load_pem_private_key(pem_data, password=passphrase, backend=default_backend()) | |
| if not isinstance(priv, rsa.RSAPrivateKey): | |
| raise ValueError("Private key is not RSA format") | |
| logger.debug(f"• Valid RSA private key loaded from {path}") | |
| return pem_data, passphrase | |
| except FileNotFoundError: | |
| logger.error(f"RSA private key file not found: {path}") | |
| raise | |
| except ValueError as e: | |
| if "password" in str(e).lower(): | |
| logger.error(f"RSA private key passphrase mismatch at {path}") | |
| raise ValueError(f"Invalid RSA private key: {e}") | |
| except Exception as e: | |
| logger.error(f"Error loading RSA private key from {path}: {e}") | |
| raise | |
| def fetch_html(url: str, timeout: int = 15) -> Tuple[str, str]: | |
| # >>> Fetch HTML with robust error handling | |
| try: | |
| logger.info(f"Fetching URL: {url}") | |
| r = requests.get(url, timeout=timeout) | |
| r.raise_for_status() | |
| logger.info(f"• Successfully fetched {url} ({len(r.text)} bytes)") | |
| return r.text, get_base_url(r.text, r.url) | |
| except requests.exceptions.Timeout: | |
| logger.error(f"Timeout fetching {url} (>{timeout}s)") | |
| raise | |
| except requests.exceptions.HTTPError as e: | |
| logger.error(f"HTTP error {e.response.status_code} from {url}") | |
| raise | |
| except requests.exceptions.ConnectionError as e: | |
| logger.error(f"Connection error fetching {url}: {e}") | |
| raise | |
| except Exception as e: | |
| logger.error(f"Unexpected error fetching {url}: {type(e).__name__}: {e}") | |
| raise | |
| def extract_rdf_from_html(html: str, base: str) -> Dict: | |
| # >>> Extract RDF with error tracking | |
| logger.info("Extracting RDF (JSON-LD, RDFa, Microdata)...") | |
| try: | |
| extracted = extruct.extract(html, base_url=base, syntaxes=['json-ld', 'rdfa', 'microdata']) | |
| logger.info(f"• Extraction complete: json-ld={len(extracted.get('json-ld',[]))}, " | |
| f"rdfa={len(extracted.get('rdfa',[]))}, microdata={len(extracted.get('microdata',[]))}") | |
| return extracted | |
| except Exception as e: | |
| logger.error(f"RDF extraction failed: {e}") | |
| raise | |
| def find_linked_rdf_urls(html: str, base: str) -> List[Tuple[str, str]]: | |
| # >>> Find linked RDF resources with validation | |
| logger.info("Scanning for linked RDF resources...") | |
| soup = BeautifulSoup(html, 'html.parser') | |
| out = [] | |
| rdf_mimetypes = { | |
| 'application/ld+json', 'application/rdf+xml', | |
| 'text/turtle', 'application/n-triples', 'text/n-triples'} | |
| for link in soup.find_all('link', href=True): | |
| mimetype = link.get('type', '').lower().strip() | |
| if mimetype in rdf_mimetypes: | |
| href = requests.compat.urljoin(base, link['href']) | |
| out.append((href, mimetype)) | |
| logger.debug(f"Found linked RDF: {href} ({mimetype})") | |
| logger.info(f"• Found {len(out)} linked RDF resource(s)") | |
| return out | |
| def parse_rdf_string(payload: str, fmt_hint: str = None) -> Graph: | |
| # >>> Parse RDF with format detection and error handling | |
| g = Graph() | |
| fmt_map = { | |
| 'application/ld+json': 'json-ld', | |
| 'json-ld': 'json-ld', | |
| 'text/turtle': 'turtle', | |
| 'turtle': 'turtle', | |
| 'application/rdf+xml': 'xml', | |
| 'application/n-triples': 'nt', | |
| 'text/n-triples': 'nt'} | |
| fmt = fmt_map.get(fmt_hint, None) | |
| if fmt: | |
| try: | |
| g.parse(data=payload, format=fmt) | |
| logger.debug(f"• Parsed RDF as {fmt} (hint-based)") | |
| return g | |
| except Exception as e: | |
| logger.warning(f"Format hint {fmt_hint} failed: {e}, attempting auto-detection...") | |
| for try_fmt in ('json-ld', 'turtle', 'xml', 'nt'): | |
| try: | |
| g.parse(data=payload, format=try_fmt) | |
| logger.debug(f"• Parsed RDF as {try_fmt} (auto-detected)") | |
| return g | |
| except Exception: | |
| continue | |
| logger.error(f"Could not parse RDF payload, tried all formats") | |
| raise ValueError("Could not parse RDF payload with any supported format") | |
| def frame_jsonld_for_signal(jsonobj: Dict) -> Tuple[Dict, List]: | |
| # >>> Frame JSON-LD for Signal extraction with error handling | |
| try: | |
| frame = { | |
| "@context": { | |
| "ex": str(EX), | |
| "prov": str(PROV), | |
| "dct": str(DCT)}, | |
| "@type": "ex:Signal", | |
| "@embed": "@always"} | |
| framed = jsonld.frame(jsonobj, frame) | |
| graph = framed.get('@graph') or ([framed] if '@type' in framed else []) | |
| logger.info(f"• Framed {len(graph)} Signal object(s)") | |
| return framed, graph | |
| except Exception as e: | |
| logger.warning(f"JSON-LD framing failed: {e}") | |
| return jsonobj, [] | |
| def graph_to_canonical_nquads(graph: Graph) -> str: | |
| # >>> Convert RDF Graph to canonical N-Quads with error handling | |
| try: | |
| jld = graph.serialize(format='json-ld', indent=2) | |
| jobj = json.loads(jld) | |
| nquads = jsonld.normalize(jobj, {'algorithm': 'URDNA2015', 'format': 'application/n-quads'}) | |
| logger.info(f"• Canonicalized to {len(nquads)} N-Quads bytes") | |
| return nquads | |
| except Exception as e: | |
| logger.error(f"Graph serialization/canonicalization failed: {e}") | |
| raise ValueError(f"Cannot canonicalize RDF graph: {e}") | |
| def sign_nquads(nquads: str, priv_key: Ed25519PrivateKey, sig_registry: SignatureRegistry, key_id: str = "default") -> Tuple[str, str]: | |
| # >>> Sign N-Quads and log to signature registry | |
| try: | |
| sig = priv_key.sign(nquads.encode('utf-8')) | |
| sig_b64 = base64.b64encode(sig).decode('utf-8') | |
| sig_block = "LD-SIG:v1:alg=Ed25519;sig=" + sig_b64 | |
| data_hash = "sha256:" + hashlib.sha256(nquads.encode('utf-8')).hexdigest() | |
| sig_id = sig_registry.log_signature_creation( | |
| data_hash=data_hash, | |
| algorithm="Ed25519", | |
| key_id=key_id, | |
| signature_b64=sig_b64, | |
| data_preview=nquads[:300], | |
| data_size_bytes=len(nquads), | |
| data_type="canonicalized_nquads") | |
| logger.info(f"• Signed N-Quads with Ed25519 (signature ID: {sig_id})") | |
| return sig_block, sig_id | |
| except Exception as e: | |
| logger.error(f"Signing failed: {e}") | |
| raise | |
| def verify_nquads_signature(nquads: str, sig_block: str, | |
| pub_key: Ed25519PublicKey, | |
| sig_registry: SignatureRegistry, | |
| sig_id: str = None) -> bool: | |
| # >>> Verify N-Quads signature with logging | |
| try: | |
| if not sig_block.startswith("LD-SIG:v1:alg=Ed25519;sig="): | |
| logger.error(f"Invalid signature format: {sig_block[:50]}...") | |
| if sig_registry and sig_id: | |
| sig_registry.log_signature_verification(sig_id, False, error="Invalid format") | |
| return False | |
| b64 = sig_block.split("sig=")[1] | |
| sig = base64.b64decode(b64.encode('utf-8')) | |
| pub_key.verify(sig, nquads.encode('utf-8')) | |
| logger.info(f"• Signature verification PASSED") | |
| if sig_registry and sig_id: | |
| sig_registry.log_signature_verification(sig_id, True) | |
| return True | |
| except cryptography.exceptions.InvalidSignature: | |
| logger.warning(f"Signature verification FAILED: Invalid signature") | |
| if sig_registry and sig_id: | |
| sig_registry.log_signature_verification(sig_id, False, error="Invalid signature") | |
| return False | |
| except (ValueError, binascii.Error) as e: | |
| logger.error(f"Signature verification error (decoding): {e}") | |
| if sig_registry and sig_id: | |
| sig_registry.log_signature_verification(sig_id, False, error=f"Decoding error: {e}") | |
| return False | |
| except Exception as e: | |
| logger.error(f"Unexpected error during signature verification: {e}") | |
| if sig_registry and sig_id: | |
| sig_registry.log_signature_verification(sig_id, False, error=f"Unexpected: {e}") | |
| raise | |
| def encrypt_filename_block(plaintext_obj: Dict, key: bytes) -> str: | |
| # >>> Encrypt filename metadata block with error handling | |
| try: | |
| aesgcm = AESGCM(key) | |
| iv = os.urandom(12) | |
| pdata = json.dumps(plaintext_obj).encode('utf-8') | |
| ct = aesgcm.encrypt(iv, pdata, None) | |
| block = { | |
| "v": "1", | |
| "alg": "AES-GCM", | |
| "iv": base64.b64encode(iv).decode('utf-8'), | |
| "ct": base64.b64encode(ct).decode('utf-8')} | |
| b64 = base64.b64encode(json.dumps(block).encode('utf-8')).decode('utf-8') | |
| encrypted_block = f"ENC:{b64}" | |
| logger.debug(f"• Encrypted filename block ({len(pdata)} bytes plaintext)") | |
| return encrypted_block | |
| except Exception as e: | |
| logger.error(f"Filename encryption failed: {e}") | |
| raise ValueError(f"Encryption failed: {e}") | |
| def decrypt_filename_block(block_str: str, key: bytes) -> Dict: | |
| # >>> Decrypt filename metadata block with full error handling | |
| try: | |
| if not block_str.startswith("ENC:"): | |
| raise ValueError("Invalid encrypted block format") | |
| b64 = block_str[len("ENC:"):] | |
| raw = base64.b64decode(b64.encode('utf-8')) | |
| obj = json.loads(raw.decode('utf-8')) | |
| iv = base64.b64decode(obj['iv']) | |
| ct = base64.b64decode(obj['ct']) | |
| aesgcm = AESGCM(key) | |
| pt = aesgcm.decrypt(iv, ct, None) | |
| result = json.loads(pt.decode('utf-8')) | |
| logger.debug(f"• Decrypted filename block") | |
| return result | |
| except (ValueError, binascii.Error) as e: | |
| logger.error(f"Invalid base64 in encrypted block: {e}") | |
| raise ValueError(f"Base64 decoding failed: {e}") | |
| except Exception as e: | |
| logger.error(f"Filename decryption failed: {e}") | |
| raise ValueError(f"Decryption failed: {e}") | |
| def wrap_key_rsa_oaep(sym_key: bytes, recipient_pub_pem: bytes, recipient_id: str = "unknown") -> str: | |
| # >>> Wrap symmetric key with RSA-OAEP with full validation | |
| try: | |
| pub = serialization.load_pem_public_key(recipient_pub_pem, backend=default_backend()) | |
| if not isinstance(pub, rsa.RSAPublicKey): | |
| raise ValueError(f"Public key for {recipient_id} is not RSA format") | |
| ct = pub.encrypt(sym_key, padding.OAEP( | |
| mgf=padding.MGF1(algorithm=hashes.SHA256()), | |
| algorithm=hashes.SHA256(), | |
| label=None)) | |
| wrapped = "KW-RSA-OAEP:v1:" + base64.b64encode(ct).decode('utf-8') | |
| logger.debug(f"• Wrapped symmetric key for {recipient_id}") | |
| return wrapped | |
| except TypeError: | |
| logger.error(f"Failed to load RSA public key for {recipient_id}") | |
| raise ValueError(f"Invalid RSA public key for {recipient_id}") | |
| except Exception as e: | |
| logger.error(f"Key wrapping failed for {recipient_id}: {e}") | |
| raise ValueError(f"Key wrapping failed: {e}") | |
| def unwrap_key_rsa_oaep(wrapped_str: str, recipient_priv_pem: bytes, passphrase: bytes = None) -> bytes: | |
| # >>> Unwrap RSA-encrypted symmetric key with error handling | |
| try: | |
| if not wrapped_str.startswith("KW-RSA-OAEP:v1:"): | |
| raise ValueError("Invalid key wrapping format") | |
| ct = base64.b64decode(wrapped_str.split(":", 2)[2]) | |
| priv = serialization.load_pem_private_key(recipient_priv_pem, password=passphrase, backend=default_backend()) | |
| if not isinstance(priv, rsa.RSAPrivateKey): | |
| raise ValueError("Private key is not RSA format") | |
| key = priv.decrypt(ct, padding.OAEP( | |
| mgf=padding.MGF1(algorithm=hashes.SHA256()), | |
| algorithm=hashes.SHA256(), | |
| label=None)) | |
| logger.debug(f"• Unwrapped symmetric key") | |
| return key | |
| except (ValueError, binascii.Error) as e: | |
| logger.error(f"Key unwrapping failed: {e}") | |
| raise ValueError(f"Key unwrapping failed: {e}") | |
| except Exception as e: | |
| logger.error(f"Unexpected error unwrapping key: {e}") | |
| raise | |
| def build_signal_turtle(signal_uri: str, | |
| title: str, | |
| created_iso: str, | |
| content_hash: str, | |
| enc_fname_block: str, | |
| signature: str, | |
| sig_id: str, | |
| wrapped_keys: List[Dict], | |
| creator_uri: str = None) -> str: | |
| # >>> Build Turtle RDF Signal with enhanced metadata | |
| try: | |
| g = Graph() | |
| g.bind("ex", EX) | |
| g.bind("dct", DCT) | |
| g.bind("prov", PROV) | |
| s = URIRef(signal_uri) | |
| g.add((s, RDF.type, EX.Signal)) | |
| g.add((s, DCT.title, Literal(title))) | |
| g.add((s, DCT.created, Literal(created_iso, datatype=XSD.dateTime))) | |
| g.add((s, EX.contentHash, Literal(content_hash))) | |
| g.add((s, EX.encryptedFilenameBlock, Literal(enc_fname_block))) | |
| g.add((s, EX.signature, Literal(signature))) | |
| g.add((s, EX.signatureId, Literal(sig_id))) | |
| if creator_uri: | |
| g.add((s, DCT.creator, URIRef(creator_uri))) | |
| # >>> Add wrapped keys as blank nodes with full metadata | |
| for idx, wk in enumerate(wrapped_keys): | |
| wk_hash = hashlib.sha1(wk['wrapped'].encode()).hexdigest()[:8] | |
| bn = URIRef(f"{signal_uri}#wrappedKey-{idx}-{wk_hash}") | |
| g.add((s, EX.wrappedKey, bn)) | |
| g.add((bn, EX.keyFor, Literal(wk['recipient']))) | |
| g.add((bn, EX.wrappedValue, Literal(wk['wrapped']))) | |
| g.add((bn, EX.wrappingAlgorithm, Literal("RSA-OAEP"))) | |
| ttl = g.serialize(format='turtle') | |
| result = ttl.decode('utf-8') if isinstance(ttl, bytes) else ttl | |
| logger.info(f"• Built Turtle Signal with {len(wrapped_keys)} wrapped key(s)") | |
| return result | |
| except Exception as e: | |
| logger.error(f"Failed to build Turtle Signal: {e}") | |
| raise ValueError(f"Signal construction failed: {e}") | |
| def process_url_full(url: str, | |
| recipient_pub_pem_paths: List[str], | |
| rsa_priv_unwrap_path: str = None, | |
| rsa_priv_passphrase: bytes = None, | |
| signer_passphrase: bytes = None) -> Optional[Dict]: | |
| logger.info("=" * 80) | |
| logger.info(f"Starting RDF Signal Pipeline for: {url}") | |
| logger.info("=" * 80) | |
| # >>> Initialize tracking systems | |
| sig_registry = SignatureRegistry(sig_log_file) | |
| js_inventory = JSLibraryInventory(lib_log_file) | |
| data_tracker = VerifiableDataTracker() | |
| try: | |
| # >>> Ensure signer keys | |
| logger.info("\n[STEP 1] Initializing Ed25519 signer keypair...") | |
| signer_priv, signer_pub = ensure_ed25519_keypair(passphrase=signer_passphrase) | |
| signer_key_id = "default-ed25519" | |
| # >>> Fetch HTML | |
| logger.info("\n[STEP 2] Fetching HTML content...") | |
| html, base = fetch_html(url) | |
| data_tracker.track_data(html, "html_document", url, "fetched") | |
| # >>> Extract JS libraries | |
| logger.info("\n[STEP 3] Extracting JavaScript libraries...") | |
| js_inventory.extract_from_html(html, base) | |
| # >>> Extract RDF | |
| logger.info("\n[STEP 4] Extracting RDF data...") | |
| extracted = extract_rdf_from_html(html, base) | |
| combined_graph = Graph() | |
| # >>> Process JSON-LD from extruct | |
| jsonld_count = 0 | |
| for jd in extracted.get('json-ld', []): | |
| try: | |
| combined_graph.parse(data=json.dumps(jd), format='json-ld') | |
| jsonld_count += 1 | |
| except Exception as e: | |
| logger.warning(f"Failed to parse JSON-LD: {e}") | |
| continue | |
| logger.info(f"• Processed {jsonld_count} JSON-LD object(s)") | |
| # >>> Process RDFa from extruct | |
| rdfa_count = 0 | |
| for rdfa in extracted.get('rdfa', []): | |
| try: | |
| # >>> Handle both string and dict RDFa | |
| if isinstance(rdfa, dict): | |
| rdfa_str = json.dumps(rdfa) | |
| elif isinstance(rdfa, str): | |
| rdfa_str = rdfa | |
| else: | |
| logger.warning(f"Skipping RDFa of unexpected type: {type(rdfa)}") | |
| continue | |
| combined_graph.parse(data=rdfa_str, format='rdfa') | |
| rdfa_count += 1 | |
| except Exception as e: | |
| logger.warning(f"Failed to parse RDFa: {e}") | |
| continue | |
| logger.info(f"• Processed {rdfa_count} RDFa block(s)") | |
| # >>> Parse linked RDF files | |
| logger.info("\nFetching linked RDF resources...") | |
| linked_urls = find_linked_rdf_urls(html, base) | |
| linked_count = 0 | |
| for href, mime in linked_urls: | |
| try: | |
| r = requests.get(href, timeout=10) | |
| r.raise_for_status() | |
| content = r.content.decode('utf-8', errors='ignore') | |
| g = parse_rdf_string(content, fmt_hint=mime) | |
| for t in g: | |
| combined_graph.add(t) | |
| linked_count += 1 | |
| logger.info(f"• Processed linked RDF from {href}") | |
| except requests.exceptions.Timeout: | |
| logger.warning(f"Timeout fetching linked RDF: {href}") | |
| except requests.exceptions.HTTPError as e: | |
| logger.warning(f"HTTP {e.response.status_code} from {href}") | |
| except Exception as e: | |
| logger.warning(f"Error processing linked RDF {href}: {type(e).__name__}") | |
| logger.info(f"• Processed {linked_count} linked RDF resource(s)") | |
| # >>> Validate extracted RDF | |
| logger.info("\nValidating extracted RDF...") | |
| if len(combined_graph) == 0: | |
| logger.warning(f"No RDF triples extracted. Summary: " | |
| f"json-ld={jsonld_count}, rdfa={rdfa_count}, linked={linked_count}") | |
| return None | |
| logger.info(f"• Combined graph has {len(combined_graph)} triples") | |
| # >>> Frame JSON-LD | |
| logger.info("\nFraming JSON-LD...") | |
| jld = combined_graph.serialize(format='json-ld', indent=2) | |
| jobj = json.loads(jld) | |
| framed, signals = frame_jsonld_for_signal(jobj) | |
| # >>> Canonicalize and sign | |
| logger.info("\nCanonicalizing RDF...") | |
| nquads = graph_to_canonical_nquads(combined_graph) | |
| data_tracker.track_data(nquads, "canonical_nquads", "canonicalization", "processed") | |
| content_hash = "sha256:" + hashlib.sha256(nquads.encode('utf-8')).hexdigest() | |
| logger.info(f"• Content hash: {content_hash}") | |
| logger.info("\n[STEP 9] Signing canonical RDF...") | |
| signature, sig_id = sign_nquads(nquads, signer_priv, sig_registry, key_id=signer_key_id) | |
| # >>> Encrypt filename metadata | |
| logger.info("\nCreating encrypted filename block...") | |
| sym_key = AESGCM.generate_key(bit_length=256) | |
| filename_meta = { | |
| "filename": "extracted_signal.csv", | |
| "source_url": url, | |
| "extracted_at": datetime.utcnow().isoformat() + "Z", | |
| "triple_count": len(combined_graph)} | |
| enc_fname_block = encrypt_filename_block(filename_meta, sym_key) | |
| logger.info(f"• Encrypted filename block (metadata: {list(filename_meta.keys())})") | |
| # >>> Wrap symmetric key for recipients | |
| logger.info("\nWrapping symmetric key for recipients...") | |
| wrapped_keys = [] | |
| for pub_path in recipient_pub_pem_paths: | |
| try: | |
| recipient_id = Path(pub_path).stem | |
| recipient_pub_pem = load_rsa_pub_pem(pub_path) | |
| wrapped = wrap_key_rsa_oaep(sym_key, recipient_pub_pem, recipient_id=recipient_id) | |
| wrapped_keys.append({"recipient": recipient_id, "wrapped": wrapped}) | |
| logger.info(f"• Wrapped key for recipient: {recipient_id}") | |
| except Exception as e: | |
| logger.error(f"Failed to wrap key for {pub_path}: {e}") | |
| continue | |
| if not wrapped_keys: | |
| logger.error("No keys successfully wrapped") | |
| return None | |
| logger.info(f"• Successfully wrapped {len(wrapped_keys)} key(s)") | |
| # >>> Build Turtle Signal | |
| logger.info("\nBuilding Turtle Signal...") | |
| signal_uri = f"urn:sha256:{hashlib.sha256((content_hash + enc_fname_block).encode()).hexdigest()}" | |
| created_iso = datetime.utcnow().isoformat() + "Z" | |
| ttl = build_signal_turtle( | |
| signal_uri=signal_uri, | |
| title="Extracted RDF Signal", | |
| created_iso=created_iso, | |
| content_hash=content_hash, | |
| enc_fname_block=enc_fname_block, | |
| signature=signature, | |
| sig_id=sig_id, | |
| wrapped_keys=wrapped_keys, | |
| creator_uri=None) | |
| # >>> Verify signature | |
| logger.info("\nVerifying signature...") | |
| sig_valid = verify_nquads_signature(nquads, signature, signer_pub, sig_registry, sig_id=sig_id) | |
| logger.info(f"Signature verification: {'PASSED' if sig_valid else 'FAILED'}") | |
| # >>> Flush logs | |
| logger.info("\n[STEP 14] Flushing logs and registries...") | |
| sig_registry.flush_to_file() | |
| js_inventory.flush_to_file() | |
| logger.info("\n" + "=" * 80) | |
| logger.info("Pipeline completed successfully!") | |
| logger.info("=" * 80) | |
| return { | |
| "turtle": ttl, | |
| "signal_uri": signal_uri, | |
| "framed_signals": framed, | |
| "signature": signature, | |
| "signature_id": sig_id, | |
| "signature_valid": sig_valid, | |
| "content_hash": content_hash, | |
| "triple_count": len(combined_graph), | |
| "wrapped_keys_count": len(wrapped_keys), | |
| "js_libraries_found": len(js_inventory.libraries), | |
| "extracted_at": created_iso, | |
| "logs": { | |
| "app_log": str(app_log_file), | |
| "signature_registry": str(sig_log_file), | |
| "js_inventory": str(lib_log_file)}} | |
| except Exception as e: | |
| logger.error(f"\nPipeline failed: {type(e).__name__}: {e}", exc_info=True) | |
| return None | |
| def main(): | |
| # >>> Command-line interface for RDF Signal pipeline | |
| p = argparse.ArgumentParser( | |
| description="Extract, canonicalize, sign, and encrypt RDF signals from URLs. " | |
| "Produces Turtle output with signature tracking and JS library inventory.", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples: | |
| python rdf_signal_full.py https://example.com/page \\ | |
| --recipients /path/to/recipient_pub.pem /path/to/another_pub.pem | |
| RDF_KEY_PW="my-passphrase" python rdf_signal_full.py https://example.com \\ | |
| --recipients recipient.pem --signer-pass-env RDF_KEY_PW | |
| """) | |
| p.add_argument("url", help="URL to scan for RDF content") | |
| p.add_argument("--recipients", nargs="+", required=True, | |
| help="Paths to recipients' RSA public PEM files (required)") | |
| p.add_argument("--signer-pass-env", default=PRIVATE_KEY_PASSPHRASE_ENV, | |
| help=f"Environment variable name for signer private key passphrase " | |
| f"(default: {PRIVATE_KEY_PASSPHRASE_ENV})") | |
| p.add_argument("--output-dir", default="./output", | |
| help="Directory for output files (default: ./output)") | |
| p.add_argument("--log-dir", default="./logs", | |
| help="Directory for log files (default: ./logs)") | |
| args = p.parse_args() | |
| # >>> Create output directory | |
| output_dir = Path(args.output_dir) | |
| output_dir.mkdir(exist_ok=True) | |
| # >>> Get signer passphrase from environment if set | |
| pass_env = os.environ.get(args.signer_pass_env) | |
| pass_bytes = pass_env.encode('utf-8') if pass_env else None | |
| if pass_env: | |
| logger.info(f"Using signer passphrase from environment variable: {args.signer_pass_env}") | |
| else: | |
| logger.info(f"No signer passphrase set (environment variable: {args.signer_pass_env})") | |
| # >>> Validate recipient files exist | |
| for recipient in args.recipients: | |
| if not Path(recipient).exists(): | |
| logger.error(f"Recipient public key file not found: {recipient}") | |
| return 1 | |
| # >>> Run pipeline | |
| logger.info(f"Processing URL: {args.url}") | |
| logger.info(f"Recipients: {', '.join(Path(r).name for r in args.recipients)}") | |
| result = process_url_full(args.url, args.recipients, signer_passphrase=pass_bytes) | |
| if result is None: | |
| logger.error("Pipeline produced no output") | |
| return 1 | |
| # >>> Write Turtle output | |
| ttl_file = output_dir / "signal_output.ttl" | |
| ttl_file.write_text(result['turtle']) | |
| logger.info(f"• Turtle Signal written to: {ttl_file}") | |
| # >>> Write JSON summary | |
| summary = { | |
| "status": "success", | |
| "completed_at": datetime.utcnow().isoformat() + "Z", | |
| "source_url": args.url, | |
| "signal_uri": result['signal_uri'], | |
| "signatures": { | |
| "signature_id": result['signature_id'], | |
| "algorithm": "Ed25519", | |
| "format": "LD-SIG:v1", | |
| "verified": result['signature_valid'] | |
| }, | |
| "data": { | |
| "content_hash": result['content_hash'], | |
| "triple_count": result['triple_count'], | |
| "wrapped_keys_count": result['wrapped_keys_count'] | |
| }, | |
| "extraction": { | |
| "javascript_libraries": result['js_libraries_found'], | |
| "extracted_at": result['extracted_at'] | |
| }, | |
| "files": { | |
| "turtle_signal": str(ttl_file), | |
| "app_log": result['logs']['app_log'], | |
| "signature_registry": result['logs']['signature_registry'], | |
| "js_inventory": result['logs']['js_inventory']}} | |
| summary_file = output_dir / "summary.json" | |
| summary_file.write_text(json.dumps(summary, indent=2)) | |
| logger.info(f"• Summary written to: {summary_file}") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment