diff --git a/.github/workflows/build-pdf.yml b/.github/workflows/build-pdf.yml
index d88f7b5..e1a84fa 100644
--- a/.github/workflows/build-pdf.yml
+++ b/.github/workflows/build-pdf.yml
@@ -134,4 +134,4 @@ jobs:
export/thgtoa-dark.pdf.sig
sha256sum-light.txt
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/vt-scan.yml b/.github/workflows/vt-scan.yml
index b9d4c84..abceb77 100644
--- a/.github/workflows/vt-scan.yml
+++ b/.github/workflows/vt-scan.yml
@@ -75,4 +75,3 @@ jobs:
# Update the release with VT results
gh release edit ${{ github.ref_name }} --notes-file vt-results.md
-
diff --git a/.gitignore b/.gitignore
index 6125409..d1b3b07 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,5 +19,9 @@ ENV/
site/
_site/
_site_test/
-export/
-build/
+build/
+
+# Export directory - but track hash files and signatures
+export/thgtoa.pdf.sha256
+export/thgtoa-dark.pdf.sha256
+*.sig
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a76036..b7976a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,11 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Add ways to verify the files
+
### Changed
- Refactored GitHub Actions workflow **Build PDF** (`scripts\build_guide_pdf.py`): now builds both light and dark mode PDFs (`export/thgtoa.pdf` and `export/thgtoa-dark.pdf` respectively).
- Restored previous VT scans workflow **VirusTotal Scan** (`.github/workflows/vt-scan.yml`): submit files to VT for malware scanning. Links will be published on the site.
+## Fixed
+
+- `docs/about/index.md`: replace broken reference-style internal links
+- `docs/guide/index.md`: Appendix A6: comment out deprecated ODT information because we don't and probably won't use it in the future
+
+### Feature
+
+- Updated `scripts/build_guide_pdf.py` to use `--print-to-pdf` instead of `--save-as` for PDF generation, and added a new `--dark-mode` flag to generate dark mode PDFs. The script now supports generating both light and dark mode PDFs with a single command invocation by using the `--both` flag. This change improves the PDF generation process and provides better support for dark mode users. Save your eyes - you only get one pair.
+
## [1.2.1] - 2026-04-11
### Added
diff --git a/README.md b/README.md
index 4d10079..d357dec 100644
--- a/README.md
+++ b/README.md
@@ -13,26 +13,3 @@ This guide is an open-source non-profit initiative, [licensed](LICENSE.html) und
- **In your browser:** [Hitchhiker's Guide](https://www.anonymousplanet.org/guide/) (hosted site). After a local build you can also open `site/guide/index.html` directly.
- **Local HTML preview:** from the repository root, with Python 3 and [MkDocs Material](https://squidfunk.github.io/mkdocs-material/getting-started/) installed (`pip install mkdocs-material`), run `mkdocs serve` and open the URL printed in the terminal (for example `http://127.0.0.1:8000`).
- **PDF (local build):** from the repository root, using the same environment, run:
-
- ```bash
- python scripts/build_guide_pdf.py
- ```
-
- This runs `mkdocs build` (output defaults to `./site`), then uses **Google Chrome** or **Microsoft Edge** in headless mode to print `site/guide/index.html` to **`export/thgtoa.pdf`** (images and styling preserved). If the site is already built: `python scripts/build_guide_pdf.py --skip-mkdocs`. Other options: `--site-dir`, `--pdf`, and `python scripts/build_guide_pdf.py --help`.
-
- On **GitHub Actions**, the [Build guide PDF](https://github.com/Anon-Planet/thgtoa/actions/workflows/build-pdf.yml) workflow does the same using Chromium on Ubuntu when you push to `main` or open a pull request that touches the guide or build inputs; download the **`thgtoa.pdf`** artifact from a successful run. You can also run it manually (**Actions** → **Build guide PDF** → **Run workflow**).
-- **OpenDocument (ODT):** not produced by this repository (previous hosted export removed).
-- **Raw Markdown (very large):** [docs/guide/index.md on GitHub](https://raw.githubusercontent.com/Anon-Planet/thgtoa/refs/heads/main/docs/guide/index.md)
-
-**Mirrors:**
-- Hidden service: **Host down**
-
-Feel free to submit issues using Github Issues with the repository link above. Criticism, opinions, and ideas are welcome!
-
-**Follow or contact us on:**
-
-Discussion Channels:
-- Matrix room:
-- Matrix space:
-
-Have a good read and feel free to share and/or recommend it!
diff --git a/docs/guide/dev-workflow.md b/docs/guide/dev-workflow.md
new file mode 100644
index 0000000..c0af6b8
--- /dev/null
+++ b/docs/guide/dev-workflow.md
@@ -0,0 +1,193 @@
+# Development
+
+## Overview
+
+This repository now includes an automated workflow that handles PDF generation, verification, and distribution with the following features:
+
+??? Note "How the pipeline works"
+
+ 1. **Automatic PDF Generation** - Builds both light and dark mode PDFs from MkDocs source
+ 2. **SHA256 Hash Generation** - Creates hash files for integrity verification
+ 3. **GPG Signature Signing** - Signs all PDFs and hash files with repository GPG key
+ 4. **VirusTotal Scanning** - Automatically scans PDFs and updates release notes
+ 5. **Release Automation** - Packages everything into GitHub releases
+
+## Workflow Architecture
+
+### 1. Build PDF Workflow (`build-pdf.yml`)
+
+**Trigger:** Push to main, pull requests, or manual dispatch
+
+??? Note "Steps"
+
+ - Checkout repository
+ - Set up Python 3.13 and MkDocs Material
+ - Install Chromium browser
+ - Generate both light and dark mode PDFs
+ - Create SHA256 hash files
+ - Sign all files with GPG
+ - Upload artifacts to GitHub Actions
+ - Publish release
+
+### 2. VirusTotal Scan Workflow (`vt-scan.yml`)
+
+**Trigger:** Push to main, tags, or manual dispatch (runs after build-pdf)
+
+??? Note "Steps"
+
+ - Download PDF artifacts from build workflow
+ - Scan both PDFs with VirusTotal API
+ - Extract scan results and generate report links
+ - Update release notes with VT scan status and URLs
+
+## File Structure
+
+After a successful build, the repository will contain:
+
+```
+.../
+├── export/
+│ ├── thgtoa.pdf # Light mode PDF
+│ ├── thgtoa-dark.pdf # Dark mode PDF
+│ ├── thgtoa.pdf.sig # GPG signature (light)
+│ └── thgtoa-dark.pdf.sig # GPG signature (dark)
+├── thgtoa.pdf.sha256 # Hash file (light)
+├── thgtoa-dark.pdf.sha256 # Hash file (dark)
+├── sha256sum-light.txt # Combined hash file
+└── scripts/
+ ├── build_guide_pdf.py # PDF generation script
+ └── verify_pdf.py # Verification utility
+```
+
+## Security Features
+
+### 1. SHA256 Hash Verification
+
+**Purpose:** Ensure file integrity during download/transit
+
+**How it works:**
+- Each PDF gets a unique SHA256 hash calculated at build time
+- Hash stored in `.sha256` files alongside the PDFs
+- Combined `sha256sum-light.txt` for batch verification
+
+**Verification command:**
+```bash
+sha256sum -c sha256sum-light.txt
+```
+
+### 2. GPG Signature Verification
+
+**Purpose:** Verify authenticity and prevent tampering
+
+??? Note "How it works"
+
+ - Detached signatures created for each PDF and hash file
+ - Public keys available in `/pgp/` directory
+
+**Verification command:**
+```bash
+gpg --import pgp/anonymousplanet-master.asc
+gpg --verify export/thgtoa.pdf.sig export/thgtoa.pdf
+```
+
+### 3. VirusTotal Integration
+
+**Purpose:** Malware detection and security scanning
+
+??? Note "How it works"
+
+ - Automatic scan of all generated PDFs
+ - Results published in release notes with direct links
+ - Provides third-party validation of file safety
+
+## Usage Examples
+
+### Local Development
+
+```bash
+# Build PDFs locally
+python scripts/build_guide_pdf.py --both
+
+# Verify hashes
+python scripts/verify_pdf.py --hashes
+
+# Verify signatures (requires GPG installed)
+python scripts/verify_pdf.py --signatures
+
+# Full verification with VirusTotal check
+export VT_API_KEY=your_api_key
+python scripts/verify_pdf.py --all
+```
+
+### CI/CD Verification
+
+The workflows automatically verify everything during the build process. To manually trigger:
+
+1. Go to Actions tab
+2. Select "Build guide PDF" or "VirusTotal Scan"
+3. Click "Run workflow"
+4. Download artifacts from successful run
+
+## Release Process
+
+When you create a tag (e.g., `v1.0.0`):
+
+1. Push the tag: `git push origin v1.0.0`
+2. Build PDF workflow triggers automatically
+3. VirusTotal scan workflow runs after build completes
+4. Both workflows update/create GitHub release with:
+ - Light and dark mode PDFs
+ - GPG signatures for all files
+ - Hash files for verification
+ - Release notes with VT scan results
+
+## Troubleshooting
+
+### Common Issues
+
+**GPG signing fails:**
+- Check that `GPG_PRIVATE_KEY` is in ASCII armor format
+- Verify passphrase is correct
+- Ensure key has signing capability
+
+**Hash mismatch after download:**
+- Re-download the file (corruption during transfer)
+- Verify you're using the correct hash file
+- Check disk integrity
+
+**VirusTotal scan fails:**
+- Verify `VT_API_KEY` is set correctly
+- Check API quota limits (free tier: 4 requests/minute)
+- Ensure PDF files exist before scanning
+
+### Debug Mode
+
+Enable verbose output by adding to workflow:
+```yaml
+- name: Debug
+ run: |
+ echo "Current directory:" && pwd
+ echo "Files in export:" && ls -la export/
+ echo "Hash file contents:" && cat sha256sum-light.txt
+```
+
+## Best Practices
+
+1. **Always verify signatures** before opening PDFs from untrusted sources
+2. **Check VirusTotal results** for any suspicious detections
+3. **Keep GPG keys secure** - never commit private keys to repository
+4. **Monitor API usage** for VirusTotal to avoid rate limiting
+5. **Test locally** before pushing tags to production
+
+## Future Enhancements
+
+Potential improvements:
+- Multi-signature support (multiple maintainers)
+- Automated changelog generation with hashes
+- Cross-platform signature verification scripts
+- Integration with additional malware scanners
+- Automatic mirror updates with verified files
+
+---
+
+*This workflow is designed for security-conscious users who need to verify the authenticity and integrity of downloaded documents.*
diff --git a/docs/index.md b/docs/index.md
index ff8c3b7..9712a8f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -18,8 +18,8 @@ schema:
**9FA5 436D 0EE3 6098 5157 3825 17EC A05F 768D EDF6**
-This is the master signing key fingerprint for Anonymous Planet.
- You'll use it to [**verify the checksum** and **GPG signature** of this file for authenticity.](verify/index.md)
+This is the master signing key fingerprint for Anonymous Planet.
+ You'll use it to [**verify the checksum** and **GPG signature** of all files for authenticity.](verify/index.md)
Please share this project if you enjoy it and you think it might be useful to others.
{ align=right }
@@ -39,4 +39,3 @@ Anonymous Planet is a collective of volunteers and contributors. No one person i
- [:fontawesome-solid-house: Homepage](https://www.itsnothing.net)
- [:fontawesome-solid-envelope: E-mail](mailto:contact@anonymousplanet.org)
- [:simple-matrix: Personal Matrix](https://matrix.to/#/@thehidden:tchncs.de "@thehidden:tchncs.de"), [:simple-matrix: Org Matrix](https://matrix.to/#/@nope:anonymousplanet.net "@nope:anonymousplanet.net")
-
diff --git a/docs/verify/index.md b/docs/verify/index.md
index 63c2e09..304ff03 100644
--- a/docs/verify/index.md
+++ b/docs/verify/index.md
@@ -114,9 +114,9 @@ The GitHub Actions workflows automatically:
## Key Information
-**Signing Key:** Anonymous Planet Master Key
-**Key ID:** See `pgp/anonymousplanet-master.asc` for details
-**Fingerprint:** Verify from the repository's official documentation
+**Signing Key:** Anonymous Planet Master Key
+**Key ID:** See `pgp/anonymousplanet-master.asc` for details
+**Fingerprint:** Verify from the repository's official documentation
---
diff --git a/mkdocs.yml b/mkdocs.yml
index ef51d35..b01db56 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -125,7 +125,9 @@ nav:
- Home: index.md
- About: about/index.md
- Verify: verify/index.md
- - Guide: guide/index.md
+ - Guide:
+ - guide/index.md
+ - Workflow Documentation: guide/dev-workflow.md
- Code: code/index.md
- Contribute: contribute/index.md
- Constitution: constitution/index.md
diff --git a/scripts/build_guide_pdf.py b/scripts/build_guide_pdf.py
index b183735..944787f 100644
--- a/scripts/build_guide_pdf.py
+++ b/scripts/build_guide_pdf.py
@@ -1,16 +1,16 @@
#!/usr/bin/env python3
-"""Extended version of build_guide_pdf.py with dark mode support.
+"""Experimental dark mode support.
This script builds both light and dark mode MkDocs site, then renders docs/guide/ to single PDFs via Chromium.
Usage:
- python scripts/build_guide_pdf_extended.py # Generate light mode PDF only
- python scripts/build_guide_pdf_extended.py --dark-mode # Generate dark mode PDF only
- python scripts/build_guide_pdf_extended.py --both # Generate both light and dark mode PDFs
+ python scripts/build_guide_pdf.py # Generate light mode PDF only
+ python scripts/build_guide_pdf.py --dark-mode # Generate dark mode PDF only
+ python scripts/build_guide_pdf.py --both # Generate both light and dark mode PDFs
Examples:
- python scripts/build_guide_pdf_extended.py --site-dir build/html --pdf-light export/thgtoa.pdf
- python scripts/build_guide_pdf_extended.py --dark-mode --pdf-dark export/thgtoa-dark.pdf
+ python scripts/build_guide_pdf.py --site-dir build/html --pdf-light export/thgtoa.pdf
+ python scripts/build_guide_pdf.py --dark-mode --pdf-dark export/thgtoa-dark.pdf
"""
from __future__ import annotations
diff --git a/scripts/setup_workflow.py b/scripts/setup_workflow.py
new file mode 100644
index 0000000..1b9bf2f
--- /dev/null
+++ b/scripts/setup_workflow.py
@@ -0,0 +1,322 @@
+#!/usr/bin/env python3
+"""Setup helper for PDF workflow configuration.
+
+This script helps you configure the necessary GitHub Secrets for the automated
+PDF build, signing, and VirusTotal scanning workflows.
+
+Usage:
+ python scripts/setup_workflow.py
+
+Requirements:
+ - Python 3.8+
+ - GPG installed (for key export)
+ - Access to GitHub repository settings
+
+What it does:
+ 1. Validates your GPG key setup
+ 2. Exports the public key for verification
+ 3. Provides instructions for adding secrets to GitHub
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+
+def repo_root() -> Path:
+ return Path(__file__).resolve().parent.parent
+
+
+def check_gpg_installed() -> bool:
+ """Check if GPG is installed and accessible."""
+ try:
+ result = subprocess.run(
+ ["gpg", "--version"],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ return result.returncode == 0
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ return False
+
+
+def list_gpg_keys() -> list[dict]:
+ """List all GPG keys in the keyring."""
+ try:
+ result = subprocess.run(
+ ["gpg", "--list-keys", "--with-colons"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+
+ keys = []
+ current_key = {}
+
+ for line in result.stdout.split('\n'):
+ if line.startswith('pub:'):
+ if current_key:
+ keys.append(current_key)
+ parts = line.split(':')
+ current_key = {
+ 'type': parts[1],
+ 'key_id': parts[4],
+ 'fingerprint': parts[9] if len(parts) > 9 else None,
+ 'created': parts[5],
+ 'expires': parts[6],
+ 'uid': None,
+ }
+ elif line.startswith('uid:'):
+ parts = line.split(':')
+ current_key['uid'] = parts[9] if len(parts) > 9 else None
+
+ if current_key:
+ keys.append(current_key)
+
+ return keys
+
+ except subprocess.CalledProcessError as e:
+ print(f"Error listing GPG keys: {e}")
+ return []
+
+
+def export_public_key(key_id: str, output_file: Path | None = None) -> str | None:
+ """Export a public key in ASCII armor format."""
+ try:
+ result = subprocess.run(
+ ["gpg", "--armor", "--export", key_id],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+
+ if output_file:
+ output_file.write_text(result.stdout)
+ print(f"✓ Public key exported to {output_file}")
+
+ return result.stdout
+
+ except subprocess.CalledProcessError as e:
+ print(f"Error exporting public key: {e}")
+ return None
+
+
+def export_private_key(key_id: str, output_file: Path | None = None) -> str | None:
+ """Export a private key in ASCII armor format (requires passphrase)."""
+ try:
+ # This will prompt for passphrase interactively
+ result = subprocess.run(
+ ["gpg", "--armor", "--export-secret-keys", key_id],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+
+ if output_file:
+ output_file.write_text(result.stdout)
+ print(f"✓ Private key exported to {output_file}")
+
+ return result.stdout
+
+ except subprocess.CalledProcessError as e:
+ print(f"Error exporting private key: {e}")
+ return None
+
+
+def validate_gpg_key(key_id: str) -> bool:
+ """Validate that a GPG key has signing capability."""
+ try:
+ result = subprocess.run(
+ ["gpg", "--list-keys", "--with-colons", key_id],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+
+ # Check for 's' (signing) in the pub line
+ for line in result.stdout.split('\n'):
+ if line.startswith('pub:'):
+ flags = line.split(':')[1]
+ return 's' in flags
+
+ return False
+
+ except subprocess.CalledProcessError:
+ return False
+
+
+def print_setup_instructions():
+ """Print instructions for configuring GitHub Secrets."""
+ print("\n" + "="*70)
+ print("GITHUB SECRETS SETUP INSTRUCTIONS")
+ print("="*70)
+
+ print("""
+To enable the automated PDF workflow, you need to add three secrets to your
+GitHub repository:
+
+1. GPG_PRIVATE_KEY
+ - Your GPG private key in ASCII armor format
+ - Used to sign PDFs and hash files
+ - IMPORTANT: Keep this secret! Never commit it publicly
+
+2. GPG_PASSPHRASE
+ - The passphrase for your GPG private key
+ - Required to unlock the private key for signing
+
+3. VT_API_KEY (optional but recommended)
+ - VirusTotal API key for malware scanning
+ - Get a free key at: https://www.virustotal.com/gui/join-us
+
+
+HOW TO ADD SECRETS:
+
+1. Go to your repository on GitHub
+2. Click 'Settings' → 'Secrets and variables' → 'Actions'
+3. Click 'New repository secret' for each secret below:
+
+ Secret Name | Value Format
+ ---------------------|--------------------------------------------------
+ GPG_PRIVATE_KEY | Paste the entire ASCII armored key (BEGIN PGP...)
+ GPG_PASSPHRASE | Your key's passphrase (no special characters issues)
+ VT_API_KEY | Your VirusTotal API key
+
+
+VERIFYING YOUR SETUP:
+
+After adding secrets, you can test by:
+1. Going to 'Actions' tab
+2. Selecting 'Build guide PDF' workflow
+3. Clicking 'Run workflow'
+4. Checking if the workflow completes successfully
+
+
+TROUBLESHOOTING:
+
+- If GPG signing fails: Check that your key has signing capability ('s' flag)
+- If passphrase is wrong: Verify you're using the correct passphrase
+- If VT scan fails: Ensure API key is valid and within rate limits
+
+
+SECURITY NOTES:
+
+⚠ NEVER share your private key or passphrase publicly
+⚠ Always use repository secrets, never hardcode in scripts
+⚠ Rotate keys periodically if compromised
+⚠ Use strong passphrases (12+ characters recommended)
+""")
+
+
+def main() -> int:
+ print("\n" + "="*70)
+ print("PDF WORKFLOW SETUP HELPER")
+ print("="*70)
+
+ # Check GPG installation
+ if not check_gpg_installed():
+ print("⚠ WARNING: GPG is not installed or not in PATH")
+ print("Please install GPG before continuing:")
+ print(" - Linux: sudo apt install gnupg")
+ print(" - macOS: brew install gnupg")
+ print(" - Windows: https://www.gpg4win.org/")
+ print("\nContinuing anyway...")
+
+ # List available keys
+ print("\n🔑 Available GPG Keys:")
+ print("-" * 70)
+
+ keys = list_gpg_keys()
+
+ if not keys:
+ print("No GPG keys found in your keyring.")
+ print("Generate a key with: gpg --full-generate-key")
+ return 1
+
+ for i, key in enumerate(keys, 1):
+ status = "✓" if validate_gpg_key(key['key_id']) else "✗"
+ print(f"\n{i}. {status} Key ID: {key['key_id']}")
+ print(f" Fingerprint: {key.get('fingerprint', 'N/A')}")
+ print(f" UID: {key.get('uid', 'Unknown')}")
+ print(f" Created: {key.get('created', 'Unknown')}")
+
+ if key.get('expires'):
+ print(f" Expires: {key['expires']}")
+
+ # Ask user to select key
+ print("\n" + "-" * 70)
+ try:
+ choice = input("\nEnter the number of the key you want to use (1-{}): ".format(len(keys)))
+ selected_index = int(choice) - 1
+
+ if not (0 <= selected_index < len(keys)):
+ print("Invalid selection!")
+ return 1
+
+ except ValueError:
+ print("Invalid input! Please enter a number.")
+ return 1
+
+ selected_key = keys[selected_index]
+
+ # Validate key has signing capability
+ if not validate_gpg_key(selected_key['key_id']):
+ print(f"\n⚠ WARNING: Selected key does not have signing capability!")
+ print("You need a key with 's' (signing) flag for PDF signatures.")
+ confirm = input("Continue anyway? (y/N): ")
+ if confirm.lower() != 'y':
+ return 1
+
+ # Export public key
+ print(f"\n📤 Exporting public key for {selected_key['uid']}...")
+ public_key_file = repo_root() / "pgp" / "workflow-public.asc"
+
+ public_key = export_public_key(selected_key['key_id'], public_key_file)
+
+ if not public_key:
+ print("Failed to export public key!")
+ return 1
+
+ # Show public key info
+ print("\n✓ Public Key Information:")
+ print("-" * 70)
+ for line in public_key.split('\n')[:5]:
+ print(line)
+ print("...")
+
+ # Instructions for private key export
+ print("\n🔐 Private Key Export:")
+ print("-" * 70)
+ print("""
+To get your private key for the GPG_PRIVATE_KEY secret:
+
+1. Run this command (you'll be prompted for passphrase):
+ gpg --armor --export-secret-keys {} > workflow-private.asc
+
+2. Copy the ENTIRE output including BEGIN and END lines
+
+3. Add it to GitHub Secrets as 'GPG_PRIVATE_KEY'
+
+⚠ IMPORTANT: Keep your private key secure! Never commit it publicly.
+""".format(selected_key['key_id']))
+
+ # Print setup instructions
+ print_setup_instructions()
+
+ print("\n" + "="*70)
+ print("SETUP COMPLETE!")
+ print("="*70)
+ print(f"\nPublic key saved to: {public_key_file}")
+ print("Next steps:")
+ print("1. Export your private key (see instructions above)")
+ print("2. Add all three secrets to GitHub repository settings")
+ print("3. Test the workflow by triggering a manual build")
+ print("\nFor more information, see: docs/guide/pdf-workflow.md\n")
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/verify_pdf.py b/scripts/verify_pdf.py
new file mode 100644
index 0000000..7f31bdb
--- /dev/null
+++ b/scripts/verify_pdf.py
@@ -0,0 +1,222 @@
+#!/usr/bin/env python3
+"""Verification script for PDF files.
+
+This script verifies:
+1. SHA256 hash integrity of PDF files
+2. GPG signature authenticity
+3. VirusTotal scan status (optional)
+
+Usage:
+ python scripts/verify_pdf.py --all # Verify everything
+ python scripts/verify_pdf.py --hashes # Only verify hashes
+ python scripts/verify_pdf.py --signatures # Only verify signatures
+ python scripts/verify_pdf.py --vt # Check VT status (requires API key)
+
+Examples:
+ python scripts/verify_pdf.py --all
+ python scripts/verify_pdf.py --hashes --file export/thgtoa.pdf
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+
+def repo_root() -> Path:
+ return Path(__file__).resolve().parent.parent
+
+
+def calculate_sha256(file_path: Path) -> str:
+ """Calculate SHA256 hash of a file."""
+ sha256_hash = hashlib.sha256()
+ with open(file_path, "rb") as f:
+ for byte_block in iter(lambda: f.read(4096), b""):
+ sha256_hash.update(byte_block)
+ return sha256_hash.hexdigest()
+
+
+def verify_hash(file_path: Path, expected_hash: str) -> bool:
+ """Verify file hash against expected value."""
+ actual_hash = calculate_sha256(file_path)
+ is_valid = actual_hash == expected_hash
+ status = "✓ PASS" if is_valid else "✗ FAIL"
+ print(f"{status}: {file_path.name}")
+ print(f" Expected: {expected_hash}")
+ print(f" Actual: {actual_hash}")
+ return is_valid
+
+
+def verify_signature(file_path: Path, sig_file: Path) -> bool:
+ """Verify GPG signature of a file."""
+ if not sig_file.exists():
+ print(f"✗ FAIL: Signature file not found: {sig_file}")
+ return False
+
+ try:
+ result = subprocess.run(
+ ["gpg", "--verify", str(sig_file), str(file_path)],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ if result.returncode == 0:
+ print(f"✓ PASS: {file_path.name} signature verified")
+ # Extract key info from GPG output
+ for line in result.stdout.split('\n'):
+ if 'Good signature' in line or 'key ID' in line.lower():
+ print(f" {line.strip()}")
+ return True
+ else:
+ print(f"✗ FAIL: {file_path.name} signature verification failed")
+ print(f" Error: {result.stderr}")
+ return False
+
+ except FileNotFoundError:
+ print("⚠ WARNING: GPG not installed. Skipping signature verification.")
+ return None
+
+
+def verify_from_hash_file(file_path: Path, hash_file: Path) -> bool:
+ """Verify file hash from a hash file."""
+ if not hash_file.exists():
+ print(f"✗ FAIL: Hash file not found: {hash_file}")
+ return False
+
+ expected_hash = None
+ with open(hash_file, 'r') as f:
+ for line in f:
+ parts = line.strip().split()
+ if len(parts) >= 2 and parts[1] == str(file_path):
+ expected_hash = parts[0]
+ break
+
+ if not expected_hash:
+ print(f"✗ FAIL: Hash not found in {hash_file.name} for {file_path.name}")
+ return False
+
+ return verify_hash(file_path, expected_hash)
+
+
+def check_virustotal(file_hash: str, api_key: str | None = None) -> dict | None:
+ """Check VirusTotal scan status for a file hash."""
+ if not api_key:
+ print("⚠ WARNING: VT_API_KEY not set. Skipping VirusTotal check.")
+ return None
+
+ try:
+ import urllib.request
+ import json
+
+ url = f"https://www.virustotal.com/api/v3/files/{file_hash}"
+ request = urllib.request.Request(url, headers={"x-apikey": api_key})
+
+ with urllib.request.urlopen(request, timeout=30) as response:
+ data = json.loads(response.read().decode())
+
+ stats = data.get('data', {}).get('attributes', {}).get('last_analysis_stats', {})
+ total = sum(stats.values()) if stats else 0
+
+ print(f"\n🦠 VirusTotal Results for {file_hash[:16]}...")
+ print(f" Total scans: {total}")
+
+ if stats:
+ print(f" Malicious: {stats.get('malicious', 0)}")
+ print(f" Suspicious: {stats.get('suspicious', 0)}")
+ print(f" Undetected: {stats.get('undetected', 0)}")
+ print(f" Clean: {stats.get('harmless', 0)}")
+
+ return data
+
+ except Exception as e:
+ print(f"⚠ ERROR checking VirusTotal: {e}")
+ return None
+
+
+def main() -> int:
+ root = repo_root()
+ ap = argparse.ArgumentParser(description="Verify PDF files (hashes, signatures, VT).")
+
+ # File paths
+ ap.add_argument(
+ "--light-pdf",
+ type=Path,
+ default=root / "export" / "thgtoa.pdf",
+ help="Light mode PDF file",
+ )
+ ap.add_argument(
+ "--dark-pdf",
+ type=Path,
+ default=root / "export" / "thgtoa-dark.pdf",
+ help="Dark mode PDF file",
+ )
+ ap.add_argument(
+ "--hash-file",
+ type=Path,
+ default=root / "sha256sum-light.txt",
+ help="Hash file to verify against",
+ )
+
+ # Verification modes
+ group = ap.add_mutually_exclusive_group()
+ group.add_argument("--all", action="store_true", help="Verify everything")
+ group.add_argument("--hashes", action="store_true", help="Only verify hashes")
+ group.add_argument("--signatures", action="store_true", help="Only verify signatures")
+ ap.add_argument("--vt", action="store_true", help="Check VirusTotal status")
+
+ args = ap.parse_args()
+
+ # Determine what to verify
+ if not any([args.all, args.hashes, args.signatures, args.vt]):
+ args.all = True
+
+ all_passed = True
+
+ pdf_files = [
+ ("Light", args.light_pdf),
+ ("Dark", args.dark_pdf),
+ ]
+
+ for mode_name, pdf_file in pdf_files:
+ if not pdf_file.exists():
+ print(f"⚠ WARNING: {pdf_file.name} not found. Skipping.")
+ continue
+
+ print(f"\n{'='*60}")
+ print(f"Verifying {mode_name} PDF: {pdf_file.name}")
+ print('='*60)
+
+ # Verify hash if requested
+ if args.all or args.hashes:
+ if not verify_from_hash_file(pdf_file, args.hash_file):
+ all_passed = False
+
+ # Verify signature if requested
+ if args.all or args.signatures:
+ sig_file = pdf_file.with_suffix(pdf_file.suffix + ".sig")
+ result = verify_signature(pdf_file, sig_file)
+ if result is False: # None means skipped (GPG not installed)
+ all_passed = False
+
+ # Check VirusTotal if requested
+ if args.all or args.vt:
+ file_hash = calculate_sha256(pdf_file)
+ api_key = os.environ.get("VT_API_KEY")
+ check_virustotal(file_hash, api_key)
+
+ print(f"\n{'='*60}")
+ if all_passed:
+ print("✓ All verifications PASSED")
+ return 0
+ else:
+ print("✗ Some verifications FAILED")
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())