Skip to content

Instantly share code, notes, and snippets.

@thefranke
Created September 15, 2026 13:47
Show Gist options
  • Select an option

  • Save thefranke/7c2808078a73b22f1c397312a229bd1b to your computer and use it in GitHub Desktop.

Select an option

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
#!/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