Created
September 15, 2026 13:47
-
-
Save thefranke/7c2808078a73b22f1c397312a229bd1b to your computer and use it in GitHub Desktop.
sign-or-verify - Sign or verify a webpage or RSS document with GPG
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 python | |
| # sign-or-verify - Sign or verify a webpage or RSS document with GPG | |
| # https://gist.github.com/thefranke/ | |
| import time | |
| import sys | |
| import base64 | |
| import tempfile | |
| import pathlib | |
| import email.utils | |
| import subprocess | |
| import datetime | |
| from lxml import etree | |
| def gpg_key_created_timestamp(key_id: str) -> int: | |
| proc = subprocess.run([ | |
| "gpg", | |
| "--with-colons", | |
| "--fixed-list-mode", | |
| "--list-secret-keys", | |
| key_id | |
| ], | |
| capture_output=True, | |
| text=True, | |
| check=True) | |
| for line in proc.stdout.splitlines(): | |
| parts = line.split(":") | |
| if parts and parts[0] in ("sec", "pub"): | |
| created_epoch = parts[5] | |
| if created_epoch.isdigit(): | |
| return int(created_epoch) | |
| raise ValueError(f"Key creation time not found for {key_id}") | |
| def gpg_sign(data: str, key_id: str, epoch: int): | |
| created_ts = gpg_key_created_timestamp(key_id) | |
| now_ts = time.time() | |
| epoch = max(epoch, created_ts) | |
| epoch = min(epoch, now_ts) | |
| data_bytes = data.encode("utf-8") | |
| p = subprocess.run([ | |
| "gpg", | |
| f"--local-user={key_id}", | |
| "--detach-sign", | |
| "--armor", | |
| f"--faked-system-time={epoch}", | |
| ], | |
| input=data_bytes, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE | |
| ) | |
| signature = base64.b64encode(p.stdout).decode("utf-8") | |
| return signature | |
| def gpg_verify(data: str, signature: str) -> str: | |
| if not data or not signature: | |
| return False | |
| data_bytes = data.encode("utf-8") | |
| signature_bytes = base64.b64decode(signature.encode("utf-8")) | |
| with tempfile.TemporaryDirectory() as d: | |
| data_path = pathlib.Path(d) / "data.bin" | |
| data_path.write_bytes(data_bytes) | |
| p = subprocess.run([ | |
| "gpg", | |
| "--verify", | |
| "/dev/stdin", | |
| data_path | |
| ], | |
| input=signature_bytes, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE | |
| ) | |
| msg = p.stderr.decode("utf-8", "replace") | |
| if "good signature" in msg.lower(): | |
| return True | |
| return False | |
| def verify(path: pathlib.Path, tree: etree): | |
| root = tree.getroot() | |
| root_tag = root.tag.lower() | |
| if root_tag == "html": | |
| comments = tree.xpath('//comment()') | |
| last_comment = comments[-1] if comments else None | |
| if last_comment is not None: | |
| signature = last_comment.text.strip() | |
| raw_without_signature = path.read_text(encoding="utf-8") | |
| raw_without_signature = raw_without_signature.rsplit('\n', 1)[0] | |
| r = gpg_verify(raw_without_signature, signature) | |
| title = tree.xpath('string(//title)').strip() | |
| print(f"{"Good" if r else "Bad"}: {title}") | |
| else: | |
| print("No Signature") | |
| if root_tag == "rss": | |
| channel = root.find("channel") | |
| if channel is None: | |
| raise ValueError("No <channel> element found") | |
| for item in channel.findall("item"): | |
| description = item.find("description") | |
| guid = item.find("guid") | |
| r = gpg_verify(description.text.strip(), guid.text.strip()) | |
| print(f"{"Good" if r else "Bad"}: {item.find("title").text}") | |
| def sign(path: pathlib.Path, tree: etree, key_id: str): | |
| root = tree.getroot() | |
| root_tag = root.tag.lower() | |
| if root_tag == "html": | |
| pubdate = tree.xpath('//meta[@property="og:published_time"]/@content') or tree.xpath('//meta[@property="article:published_time"]/@content') | |
| date = datetime.datetime.fromisoformat(pubdate[0]) | |
| ts = date.timestamp() | |
| # use this instead of etree to keep document original | |
| data = path.read_text(encoding="utf-8") | |
| signature = gpg_sign(data, key_id, ts) | |
| path.write_text(f"{data}\n<!-- {signature} -->") | |
| title = tree.xpath('string(//title)').strip() | |
| print(f"Signed HTML: {title}") | |
| if root_tag == "rss": | |
| channel = root.find("channel") | |
| if channel is None: | |
| raise ValueError("No <channel> element found") | |
| for item in channel.findall("item"): | |
| title = item.find("title") | |
| description = item.find("description") | |
| pubDate = item.find("pubDate") | |
| guid = item.find("guid") | |
| date = email.utils.parsedate_to_datetime(pubDate.text) | |
| ts = date.timestamp() | |
| guid.text = gpg_sign(description.text.strip(), key_id, ts) | |
| print(f"Signed RSS item: {title.text}") | |
| tree.write(path, pretty_print=False, encoding="utf-8", xml_declaration=False, method="xml") | |
| if __name__ == "__main__": | |
| mode = sys.argv[1] | |
| filepath = sys.argv[2] | |
| key_id = sys.argv[3] | |
| path = pathlib.Path(filepath) | |
| tree = etree.parse(path, etree.HTMLParser() if path.suffix == ".html" else etree.XMLParser(strip_cdata=False, remove_blank_text=False, compact=False)) | |
| if mode == "-s": | |
| sign(path, tree, key_id) | |
| if mode == "-v": | |
| verify(path, tree) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment