diff --git a/Pipfile b/Pipfile index 6b8a0a8..f7f0221 100644 --- a/Pipfile +++ b/Pipfile @@ -24,6 +24,7 @@ pip-install = "==1.3.5" aiohttp = "==3.8.5" openai = "==1.75.0" google-generativeai = "==0.8.5" +python-gnupg = "==0.5.3" [requires] python_version = "3.11.12" diff --git a/README.md b/README.md index fdc2607..00a6d2c 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ The official [VLDC](https://vldc.org) telegram group bot. * šŸ‘ smell like PRISM? nononono! * šŸ’° kozula Don't argue with kozula rate! * 🤫 buktopuha Let's play a game 🤔 +* 🐦 chirp – warrant canary with PGP signature ([docs](WARRANT_CANARY.md)) ### Modes * 😼 smile mode – allow only stickers in the chat diff --git a/WARRANT_CANARY.md b/WARRANT_CANARY.md new file mode 100644 index 0000000..8750b50 --- /dev/null +++ b/WARRANT_CANARY.md @@ -0,0 +1,163 @@ +# Warrant Canary - Chirp Command + +## Overview + +The warrant canary feature allows users to verify that the bot is operating normally and without interference. When a user sends the `/chirp` command, the bot responds with "meow" signed with its PGP key. + +## How It Works + +The warrant canary is implemented as follows: + +1. **User sends `/chirp` command** - Anyone can verify the bot is operational +2. **Bot responds with signed "meow"** - The message is cryptographically signed with the bot's private PGP key +3. **Users can verify the signature** - Using the bot's public key, users can verify the response is authentic + +If the bot doesn't respond or responds without a valid signature, it may indicate: +- The bot is down +- The bot has been compromised +- The bot's GPG keys have been lost or tampered with + +## Setup + +### 1. Install Dependencies + +The warrant canary requires the `python-gnupg` package, which is already included in `Pipfile`: + +```bash +pipenv install +``` + +### 2. Install GPG + +The system needs GPG installed: + +```bash +# On Ubuntu/Debian (used in dev Docker) +apt-get install gnupg + +# On Alpine (for Docker) +apk add gnupg +``` + +**Docker Setup**: Add the following line to your Dockerfile after the apt-get update: +```dockerfile +RUN apt-get -y update && apt-get install -y ffmpeg build-essential gnupg +``` + +### 3. Generate GPG Key + +Run the key generation script to create a GPG key pair for the bot: + +```bash +# Inside the container or environment +cd /app/bot +python generate_gpg_key.py +``` + +Or with a custom GPG home directory: + +```bash +python generate_gpg_key.py --gpg-home /path/to/.gnupg +``` + +This will: +- Generate a 2048-bit RSA key pair +- Store the keys in `/app/.gnupg` (or specified directory) +- Export the public key to `nyan_bot_public.asc` +- Display the public key for sharing + +### 4. Share Public Key + +After generating the key, share the public key with your users so they can verify signed messages: + +```bash +cat /app/.gnupg/nyan_bot_public.asc +``` + +Users can import this key with: + +```bash +gpg --import nyan_bot_public.asc +``` + +## Usage + +### For Users + +To check if the bot is operational: + +``` +/chirp +``` + +Expected response: +``` +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA256 + +meow +-----BEGIN PGP SIGNATURE----- +[signature data] +-----END PGP SIGNATURE----- +``` + +### Verifying the Signature + +1. Copy the signed message +2. Save it to a file (e.g., `response.txt`) +3. Verify with GPG: + +```bash +gpg --verify response.txt +``` + +You should see output indicating the signature is valid: +``` +gpg: Good signature from "VLDC Nyan Bot " +``` + +## Troubleshooting + +### Bot responds with "meow" without signature + +This means: +- GPG keys haven't been generated yet +- The `python-gnupg` package is not installed +- The GPG home directory is not accessible + +### No response to `/chirp` + +This means the bot is down or not receiving messages. + +### Invalid signature + +This may indicate: +- The bot has been compromised +- The keys have been replaced +- There's a bug in the signing implementation + +## Security Considerations + +1. **Private Key Security**: The bot's private key is stored without a passphrase to allow automated signing. Ensure the key directory (`/app/.gnupg`) has appropriate permissions (700). + +2. **Key Rotation**: Consider rotating the GPG key periodically and announcing the new public key to users. + +3. **Regular Testing**: Users should regularly test the `/chirp` command to ensure it's working as expected. + +4. **Public Key Distribution**: Distribute the public key through multiple trusted channels (website, GitHub, etc.) to prevent MITM attacks. + +## Implementation Details + +- **Skill**: `bot/skills/chirp.py` +- **Key Generation**: `bot/generate_gpg_key.py` +- **Tests**: `bot/tests/chirp_test.py` +- **Key Storage**: `/app/.gnupg` (default) +- **Key Type**: RSA 2048-bit +- **Key Usage**: Signing only +- **Signature Format**: Clear-signed ASCII-armored + +## References + +- [Warrant Canary on Wikipedia](https://en.wikipedia.org/wiki/Warrant_canary) +- [GnuPG Documentation](https://gnupg.org/documentation/) +- [python-gnupg Documentation](https://gnupg.readthedocs.io/) diff --git a/bot/generate_gpg_key.py b/bot/generate_gpg_key.py new file mode 100755 index 0000000..1eb12ab --- /dev/null +++ b/bot/generate_gpg_key.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +Generate GPG key for Nyan bot warrant canary. + +This script generates a PGP key pair for the bot to sign warrant canary messages. +Run this script once during bot setup. +""" + +import sys +from pathlib import Path + +try: + import gnupg +except ImportError: + print("Error: python-gnupg is not installed") + print("Install it with: pip install python-gnupg") + sys.exit(1) + + +def generate_bot_key(gpg_home: str = "/app/.gnupg"): + """ + Generate a GPG key for the Nyan bot. + + Args: + gpg_home: Directory to store GPG keys (default: /app/.gnupg) + """ + # Create GPG home directory if it doesn't exist + gpg_home_path = Path(gpg_home) + gpg_home_path.mkdir(parents=True, exist_ok=True, mode=0o700) + + # Create gpg.conf for batch mode + gpg_conf = gpg_home_path / "gpg.conf" + with open(gpg_conf, "w", encoding="utf-8") as f: + f.write("pinentry-mode loopback\n") + + # Initialize GPG + gpg = gnupg.GPG(gnupghome=str(gpg_home_path)) + gpg.encoding = "utf-8" + + # Check if key already exists + keys = gpg.list_keys() + if keys: + print("GPG key already exists:") + for key in keys: + print(f" Key ID: {key['keyid']}") + print(f" UID: {key['uids']}") + print(f" Fingerprint: {key['fingerprint']}") + return + + # Generate key using batch mode (more reliable for automation) + print("Generating GPG key for Nyan bot...") + batch_input = """ +%echo Generating key for VLDC Nyan Bot +Key-Type: RSA +Key-Length: 2048 +Key-Usage: sign +Name-Real: VLDC Nyan Bot +Name-Email: nyan@vldc.org +Expire-Date: 0 +%no-protection +%commit +%echo Done +""" + + key = gpg.gen_key(batch_input) + + if key: + print("\nāœ… GPG key generated successfully!") + print(f"Key ID: {key}") + + # Export public key for verification + public_key = gpg.export_keys(str(key)) + if public_key: + public_key_file = gpg_home_path / "nyan_bot_public.asc" + with open(public_key_file, "w", encoding="utf-8") as f: + f.write(public_key) + print(f"\nšŸ“„ Public key exported to: {public_key_file}") + print("\nPublic key (for verification):") + print("=" * 80) + print(public_key) + print("=" * 80) + print("\nShare this public key so users can verify signed messages!") + else: + print("āŒ Failed to generate GPG key") + sys.exit(1) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="Generate GPG key for Nyan bot warrant canary" + ) + parser.add_argument( + "--gpg-home", + default="/app/.gnupg", + help="Directory to store GPG keys (default: /app/.gnupg)", + ) + + args = parser.parse_args() + + try: + generate_bot_key(args.gpg_home) + except Exception as exc: # pylint: disable=broad-exception-caught + print(f"āŒ Error: {exc}") + sys.exit(1) diff --git a/bot/skills/__init__.py b/bot/skills/__init__.py index c092fb4..6d4ec56 100644 --- a/bot/skills/__init__.py +++ b/bot/skills/__init__.py @@ -31,6 +31,7 @@ from skills.uwu import add_uwu from skills.buktopuha import add_buktopuha from skills.chat import add_chat_mode +from skills.chirp import add_chirp logger = logging.getLogger(__name__) VERSION = "0.10.0" @@ -92,6 +93,7 @@ def _make_skill(add_handlers: Callable, name: str, hint: str) -> Dict: _make_skill(add_kozula, "šŸ’° kozula", " Don't argue with kozula rate!"), _make_skill(add_length, "šŸ† length", " length of your instrument"), _make_skill(add_buktopuha, "🤫 start BukToPuHa", " let's play a game"), + _make_skill(add_chirp, "🐦 chirp", " warrant canary - meow!"), # modes _make_skill(add_trusted_mode, "šŸ‘ā€šŸ—Ø in god we trust", " are you worthy hah?"), _make_skill(add_aoc_mode, "šŸŽ„ AOC notifier", " kekV"), @@ -127,6 +129,7 @@ def _make_skill(add_handlers: Callable, name: str, hint: str) -> Dict: ("longest", "size doesn't matter, or is it?"), ("buktopuha", "let's play a game 🤔"), ("znatoki", "top BuKToPuHa players"), + ("chirp", "🐦 warrant canary - meow!"), ] diff --git a/bot/skills/chirp.py b/bot/skills/chirp.py new file mode 100644 index 0000000..e6a1744 --- /dev/null +++ b/bot/skills/chirp.py @@ -0,0 +1,89 @@ +""" +Warrant Canary skill - /chirp command + +This skill implements a warrant canary feature where the bot responds +to /chirp with a PGP-signed "meow" message to prove authenticity. +""" + +import logging +from pathlib import Path + +from telegram import Update +from telegram.ext import Updater, CallbackContext + +from handlers import ChatCommandHandler + +logger = logging.getLogger(__name__) + +# GPG key configuration +GPG_KEY_DIR = Path("/app/.gnupg") +GPG_KEY_ID = None # Will be set after key generation + + +def add_chirp(upd: Updater, handlers_group: int): + """Register chirp command handler""" + logger.info("registering chirp handlers") + dp = upd.dispatcher + dp.add_handler(ChatCommandHandler("chirp", chirp), handlers_group) + + +def _get_pgp_signature() -> str: + """ + Get PGP signature for the meow message. + + Returns: + PGP signature as a string, or empty string if signing fails + """ + try: + import gnupg # pylint: disable=import-outside-toplevel + + # Initialize GPG + gpg_home = str(GPG_KEY_DIR) + gpg = gnupg.GPG(gnupghome=gpg_home) + + # Check if key exists + keys = gpg.list_keys() + if not keys: + logger.warning("No GPG key found for signing") + return "" + + # Sign the message + message = "meow" + signed_data = gpg.sign(message, keyid=keys[0]["keyid"], clearsign=True) + + if signed_data: + return str(signed_data) + + # pylint: disable=no-member + logger.error("Failed to sign message: %s", signed_data.stderr) + return "" + + except ImportError: + logger.warning("python-gnupg not installed, skipping signature") + return "" + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error generating PGP signature: %s", e) + return "" + + +def chirp(update: Update, context: CallbackContext): + """ + Respond to /chirp command with a signed meow message. + + This is the warrant canary - if the bot doesn't respond or responds + without a valid signature, something might be wrong. + """ + chat_id = update.effective_chat.id + + # Get PGP signature + signed_message = _get_pgp_signature() + + if signed_message: + # Send signed message in a code block for better formatting + response = f"```\n{signed_message}\n```" + context.bot.send_message(chat_id, response, parse_mode="Markdown") + else: + # Fallback to unsigned message if signing fails + context.bot.send_message(chat_id, "meow") + + logger.info("Chirp command executed for chat %s", chat_id) diff --git a/bot/tests/chirp_test.py b/bot/tests/chirp_test.py new file mode 100644 index 0000000..f3a3ef5 --- /dev/null +++ b/bot/tests/chirp_test.py @@ -0,0 +1,92 @@ +""" +Tests for the chirp (warrant canary) skill. +""" + +from unittest import TestCase +from unittest.mock import Mock, patch, MagicMock +from pathlib import Path +import sys + +# We need to mock telegram modules before importing skills +sys.modules["telegram"] = MagicMock() +sys.modules["telegram.ext"] = MagicMock() +sys.modules["telegram.error"] = MagicMock() + +from skills.chirp import chirp, _get_pgp_signature + + +class ChirpTestCase(TestCase): + """Test cases for the chirp warrant canary skill.""" + + def test_chirp_basic_response(self): + """Test that chirp responds with a message.""" + # Mock the Update and CallbackContext + update = Mock() + context = Mock() + update.effective_chat.id = 12345 + context.bot.send_message = Mock() + + # Mock _get_pgp_signature to return empty (no signature) + with patch("skills.chirp._get_pgp_signature", return_value=""): + chirp(update, context) + + # Verify send_message was called with "meow" + context.bot.send_message.assert_called_once() + args = context.bot.send_message.call_args + self.assertEqual(args[0][0], 12345) # chat_id + self.assertEqual(args[0][1], "meow") # message + + def test_chirp_with_signature(self): + """Test that chirp responds with a signed message.""" + update = Mock() + context = Mock() + update.effective_chat.id = 12345 + context.bot.send_message = Mock() + + # Mock _get_pgp_signature to return a signed message + signed_msg = ( + "-----BEGIN PGP SIGNED MESSAGE-----\nmeow\n-----END PGP SIGNATURE-----" + ) + with patch("skills.chirp._get_pgp_signature", return_value=signed_msg): + chirp(update, context) + + # Verify send_message was called with the signed message in markdown + context.bot.send_message.assert_called_once() + args = context.bot.send_message.call_args + self.assertEqual(args[0][0], 12345) # chat_id + self.assertIn(signed_msg, args[0][1]) # message contains signature + self.assertEqual(args[1]["parse_mode"], "Markdown") + + def test_get_pgp_signature_no_gnupg(self): + """Test _get_pgp_signature when gnupg is not available.""" + with patch("skills.chirp.gnupg", None): + # Should return empty string if gnupg import fails + signature = _get_pgp_signature() + self.assertEqual(signature, "") + + def test_get_pgp_signature_no_keys(self): + """Test _get_pgp_signature when no GPG keys are available.""" + mock_gpg = Mock() + mock_gpg.list_keys.return_value = [] + + with patch("skills.chirp.gnupg.GPG", return_value=mock_gpg): + signature = _get_pgp_signature() + self.assertEqual(signature, "") + + def test_get_pgp_signature_success(self): + """Test _get_pgp_signature when signing succeeds.""" + mock_gpg = Mock() + mock_gpg.list_keys.return_value = [{"keyid": "test123"}] + + # Create a mock signed data object + mock_signed = MagicMock() + mock_signed.__str__ = Mock(return_value="SIGNED MESSAGE") + mock_signed.__bool__ = Mock(return_value=True) + mock_gpg.sign.return_value = mock_signed + + with patch("skills.chirp.gnupg.GPG", return_value=mock_gpg): + signature = _get_pgp_signature() + self.assertEqual(signature, "SIGNED MESSAGE") + mock_gpg.sign.assert_called_once_with( + "meow", keyid="test123", clearsign=True + ) diff --git a/compose/dev/Dockerfile b/compose/dev/Dockerfile index 03ebb1a..f32547c 100644 --- a/compose/dev/Dockerfile +++ b/compose/dev/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.11.12-slim -RUN apt-get -y update && apt-get install -y ffmpeg build-essential +RUN apt-get -y update && apt-get install -y ffmpeg build-essential gnupg ENV PYTHONPATH /app diff --git a/compose/prod/Dockerfile b/compose/prod/Dockerfile index 765ff13..ad413ac 100644 --- a/compose/prod/Dockerfile +++ b/compose/prod/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.11.12-slim -RUN apt-get -y update && apt-get install -y ffmpeg +RUN apt-get -y update && apt-get install -y ffmpeg gnupg COPY . /app WORKDIR /app