From 96309319b9c3e3b3a12113ba71fa14e467ecaf54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Dec 2025 19:28:01 +0000 Subject: [PATCH] Add GPG signature verification and first-run setup wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds comprehensive GPG signature verification functionality and a first-run setup wizard for configuring GPG signing: **GPG Verification Features:** - Added `Crypto.verify_signature()` to verify GPG clearsigned messages - Added `Crypto.is_gpg_available()` to detect GPG installation - Added `Note.verify_signature()` method to verify note signatures - Verification returns status (verified/failed/unsigned) and signer info **TUI Enhancements:** - Display verification symbols in note lists: ✓ (verified), ✗ (failed), ? (unsigned) - Updated note detail view to show verification status with signer information - Added 'v' key binding in note detail view to trigger verification dialog - Verification dialog shows detailed status and helpful error messages **First-Run Wizard:** - Created `gpg_wizard.py` module with interactive setup wizard - Wizard runs on first application startup (when settings.json doesn't exist) - Detects GPG availability and informs user if not installed - Lists available secret keys and allows user to select signing key - Gracefully handles missing GPG or no available keys - Settings can be manually edited later via ~/.trace/settings.json **Implementation Details:** - GPG key ID is now stored in settings as `gpg_key_id` - All note displays show verification status for better chain-of-custody - External verification possible via standard GPG tools on exported notes - Follows existing codebase patterns (atomic writes, graceful degradation) Files modified: - trace/crypto.py: Added verification and availability check functions - trace/models/__init__.py: Added Note.verify_signature() method - trace/gpg_wizard.py: New first-run setup wizard module - trace/cli.py: Integrated wizard before TUI launch - trace/tui_app.py: Added verification display and dialog --- trace/cli.py | 4 ++ trace/crypto.py | 87 ++++++++++++++++++++++++++ trace/gpg_wizard.py | 131 +++++++++++++++++++++++++++++++++++++++ trace/models/__init__.py | 19 +++++- trace/tui_app.py | 123 +++++++++++++++++++++++++++++++++--- 5 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 trace/gpg_wizard.py diff --git a/trace/cli.py b/trace/cli.py index 6101a19..46ff267 100644 --- a/trace/cli.py +++ b/trace/cli.py @@ -161,6 +161,10 @@ def main(): quick_add_note(args.note) return + # Check for first run and run GPG wizard if needed + from .gpg_wizard import check_and_run_wizard + check_and_run_wizard() + # Launch TUI (with optional direct navigation to active context) try: from .tui_app import run_tui diff --git a/trace/crypto.py b/trace/crypto.py index 4b7fb4a..a92dd8c 100644 --- a/trace/crypto.py +++ b/trace/crypto.py @@ -2,6 +2,93 @@ import subprocess import hashlib class Crypto: + @staticmethod + def is_gpg_available() -> bool: + """ + Check if GPG is available on the system. + + Returns: + True if GPG is available, False otherwise. + """ + try: + proc = subprocess.Popen( + ['gpg', '--version'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + stdout, stderr = proc.communicate(timeout=5) + return proc.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + @staticmethod + def verify_signature(signed_content: str) -> tuple[bool, str]: + """ + Verify a GPG clearsigned message. + + Args: + signed_content: The clearsigned content to verify + + Returns: + A tuple of (verified: bool, signer_info: str) + - verified: True if signature is valid, False otherwise + - signer_info: Information about the signer (key ID, name) or error message + """ + if not signed_content or not signed_content.strip(): + return False, "No signature present" + + # Check if content looks like a GPG signed message + if "-----BEGIN PGP SIGNED MESSAGE-----" not in signed_content: + return False, "Not a GPG signed message" + + try: + proc = subprocess.Popen( + ['gpg', '--verify'], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + stdout, stderr = proc.communicate(input=signed_content, timeout=10) + + if proc.returncode == 0: + # Parse signer info from stderr (GPG outputs verification info to stderr) + signer_info = "Unknown signer" + for line in stderr.split('\n'): + if "Good signature from" in line: + # Extract the signer name/email + parts = line.split('"') + if len(parts) >= 2: + signer_info = parts[1] + break + elif "using" in line: + # Try to get key ID + if "key" in line.lower(): + signer_info = line.strip() + + return True, signer_info + else: + # Signature verification failed + error_msg = "Verification failed" + for line in stderr.split('\n'): + if "BAD signature" in line: + error_msg = "BAD signature" + break + elif "no public key" in line or "public key not found" in line: + error_msg = "Public key not found in keyring" + break + elif "Can't check signature" in line: + error_msg = "Cannot check signature" + break + + return False, error_msg + + except (FileNotFoundError, subprocess.TimeoutExpired): + return False, "GPG not available or timeout" + except Exception as e: + return False, f"Error: {str(e)}" + @staticmethod def list_gpg_keys(): """ diff --git a/trace/gpg_wizard.py b/trace/gpg_wizard.py new file mode 100644 index 0000000..5038c91 --- /dev/null +++ b/trace/gpg_wizard.py @@ -0,0 +1,131 @@ +"""First-run GPG setup wizard for trace application""" + +import sys +from .crypto import Crypto +from .storage import StateManager + + +def run_gpg_wizard(): + """ + Run the first-time GPG setup wizard. + + Returns: + dict: Settings to save (gpg_enabled, gpg_key_id) + """ + print("\n" + "="*60) + print("Welcome to trace - Forensic Note Taking Tool") + print("="*60) + print("\nFirst-time setup: GPG Signature Configuration\n") + print("trace can digitally sign all notes using GPG for authenticity") + print("and integrity verification. This is useful for legal evidence") + print("and chain-of-custody documentation.\n") + + # Check if GPG is available + gpg_available = Crypto.is_gpg_available() + + if not gpg_available: + print("⚠ GPG is not installed or not available on your system.") + print("\nTo use GPG signing, please install GPG:") + print(" - Linux: apt install gnupg / yum install gnupg") + print(" - macOS: brew install gnupg") + print(" - Windows: Install Gpg4win (https://gpg4win.org)") + print("\nYou can enable GPG signing later by editing ~/.trace/settings.json") + print("\nPress Enter to continue without GPG signing...") + input() + return {"pgp_enabled": False, "gpg_key_id": None} + + # GPG is available - ask if user wants to enable it + print("✓ GPG is available on your system.\n") + + while True: + response = input("Do you want to enable GPG signing for notes? (y/n): ").strip().lower() + if response in ['y', 'yes']: + enable_gpg = True + break + elif response in ['n', 'no']: + enable_gpg = False + break + else: + print("Please enter 'y' or 'n'") + + if not enable_gpg: + print("\nGPG signing disabled. You can enable it later in settings.") + return {"pgp_enabled": False, "gpg_key_id": None} + + # List available GPG keys + print("\nSearching for GPG secret keys...\n") + keys = Crypto.list_gpg_keys() + + if not keys: + print("⚠ No GPG secret keys found in your keyring.") + print("\nTo use GPG signing, you need to generate a GPG key first:") + print(" - Use 'gpg --gen-key' (Linux/macOS)") + print(" - Use Kleopatra (Windows)") + print("\nAfter generating a key, you can enable GPG signing by editing") + print("~/.trace/settings.json and setting 'gpg_enabled': true") + print("\nPress Enter to continue without GPG signing...") + input() + return {"pgp_enabled": False, "gpg_key_id": None} + + # Display available keys + print("Available GPG keys:\n") + for i, (key_id, user_id) in enumerate(keys, 1): + print(f" {i}. {user_id}") + print(f" Key ID: {key_id}\n") + + # Let user select a key + selected_key = None + + if len(keys) == 1: + print(f"Only one key found. Using: {keys[0][1]}") + selected_key = keys[0][0] + else: + while True: + try: + choice = input(f"Select a key (1-{len(keys)}, or 0 to use default key): ").strip() + choice_num = int(choice) + + if choice_num == 0: + print("Using GPG default key (no specific key ID)") + selected_key = None + break + elif 1 <= choice_num <= len(keys): + selected_key = keys[choice_num - 1][0] + print(f"Selected: {keys[choice_num - 1][1]}") + break + else: + print(f"Please enter a number between 0 and {len(keys)}") + except ValueError: + print("Please enter a valid number") + + print("\n✓ GPG signing enabled!") + if selected_key: + print(f" Using key: {selected_key}") + else: + print(" Using default GPG key") + + print("\nSetup complete. Starting trace...\n") + + return {"pgp_enabled": True, "gpg_key_id": selected_key} + + +def check_and_run_wizard(): + """ + Check if this is first run and run wizard if needed. + Returns True if wizard was run, False otherwise. + """ + state_manager = StateManager() + settings = state_manager.get_settings() + + # Check if wizard has already been run (presence of any GPG setting indicates setup was done) + if "pgp_enabled" in settings: + return False + + # First run - run wizard + wizard_settings = run_gpg_wizard() + + # Save settings + for key, value in wizard_settings.items(): + state_manager.set_setting(key, value) + + return True diff --git a/trace/models/__init__.py b/trace/models/__init__.py index c6a68e2..3502401 100644 --- a/trace/models/__init__.py +++ b/trace/models/__init__.py @@ -4,7 +4,7 @@ import time import hashlib import uuid from dataclasses import dataclass, field -from typing import List, Optional, Dict +from typing import List, Optional, Dict, Tuple from .extractors import TagExtractor, IOCExtractor @@ -32,6 +32,23 @@ class Note: data = f"{self.timestamp}:{self.content}".encode('utf-8') self.content_hash = hashlib.sha256(data).hexdigest() + def verify_signature(self) -> Tuple[bool, str]: + """ + Verify the GPG signature of this note. + + Returns: + A tuple of (verified: bool, info: str) + - verified: True if signature is valid, False if invalid or unsigned + - info: Signer information or error/status message + """ + # Import here to avoid circular dependency + from ..crypto import Crypto + + if not self.signature: + return False, "unsigned" + + return Crypto.verify_signature(self.signature) + @staticmethod def extract_iocs_from_text(text): """Extract IOCs from text and return as list of (ioc, type) tuples""" diff --git a/trace/tui_app.py b/trace/tui_app.py index 32350c8..ed4ae64 100644 --- a/trace/tui_app.py +++ b/trace/tui_app.py @@ -118,6 +118,73 @@ class TUI: self.flash_message = msg self.flash_time = time.time() + def verify_note_signature(self): + """Show detailed verification dialog for current note""" + if not self.current_note: + return + + verified, info = self.current_note.verify_signature() + + # Prepare dialog content + if not self.current_note.signature: + title = "Note Signature Status" + message = [ + "This note is unsigned.", + "", + "To sign notes, enable GPG signing in settings", + "and ensure you have a GPG key configured." + ] + elif verified: + title = "✓ Signature Verified" + message = [ + "The note's signature is valid.", + "", + f"Signed by: {info}", + "", + "This note has not been tampered with since signing." + ] + else: + title = "✗ Signature Verification Failed" + message = [ + "The note's signature could not be verified.", + "", + f"Reason: {info}", + "", + "Possible causes:", + "- Public key not in keyring", + "- Note content was modified after signing", + "- Signature is corrupted" + ] + + # Display dialog (reuse pattern from other dialogs) + h, w = self.stdscr.getmaxyx() + dialog_h = min(len(message) + 6, h - 4) + dialog_w = min(max(len(line) for line in [title] + message) + 6, w - 4) + start_y = (h - dialog_h) // 2 + start_x = (w - dialog_w) // 2 + + # Create dialog window + dialog = curses.newwin(dialog_h, dialog_w, start_y, start_x) + dialog.box() + + # Title + dialog.attron(curses.A_BOLD) + title_x = (dialog_w - len(title)) // 2 + dialog.addstr(1, title_x, title) + dialog.attroff(curses.A_BOLD) + + # Message + for i, line in enumerate(message): + dialog.addstr(3 + i, 2, line) + + # Footer + footer = "Press any key to close" + footer_x = (dialog_w - len(footer)) // 2 + dialog.addstr(dialog_h - 2, footer_x, footer, curses.color_pair(3)) + + dialog.refresh() + dialog.getch() + def _save_nav_position(self): """Save current navigation position before changing views""" # Create a key based on current view and context @@ -291,6 +358,22 @@ class TUI: return ellipsis[:max_width] + def _get_verification_symbol(self, note): + """ + Get verification symbol for a note: ✓ (verified), ✗ (failed), ? (unsigned) + + Args: + note: The Note object to check + + Returns: + str: Verification symbol + """ + if not note.signature: + return "?" + + verified, _ = note.verify_signature() + return "✓" if verified else "✗" + def _display_line_with_highlights(self, y, x_start, line, is_selected=False, win=None): """ Display a line with intelligent highlighting. @@ -823,7 +906,9 @@ class TUI: # Format note content note_content = note.content.replace('\n', ' ').replace('\r', ' ') - display_str = f"- {note_content}" + # Add verification symbol + verify_symbol = self._get_verification_symbol(note) + display_str = f"{verify_symbol} {note_content}" display_str = self._safe_truncate(display_str, self.width - 6) # Display with smart highlighting (IOCs take priority over selection) @@ -888,7 +973,9 @@ class TUI: note = notes[idx] # Replace newlines with spaces for single-line display note_content = note.content.replace('\n', ' ').replace('\r', ' ') - display_str = f"- {note_content}" + # Add verification symbol + verify_symbol = self._get_verification_symbol(note) + display_str = f"{verify_symbol} {note_content}" # Truncate safely for Unicode display_str = self._safe_truncate(display_str, self.width - 6) @@ -982,7 +1069,9 @@ class TUI: if len(content_preview) > 50: content_preview = content_preview[:50] + "..." - display_str = f"[{timestamp_str}] {content_preview}" + # Add verification symbol + verify_symbol = self._get_verification_symbol(note) + display_str = f"{verify_symbol} [{timestamp_str}] {content_preview}" display_str = self._safe_truncate(display_str, self.width - 6) if idx == self.selected_index: @@ -1080,7 +1169,9 @@ class TUI: timestamp_str = time.ctime(note.timestamp) content_preview = note.content[:60].replace('\n', ' ') + "..." if len(note.content) > 60 else note.content.replace('\n', ' ') - display_str = f"[{timestamp_str}] {content_preview}" + # Add verification symbol + verify_symbol = self._get_verification_symbol(note) + display_str = f"{verify_symbol} [{timestamp_str}] {content_preview}" display_str = self._safe_truncate(display_str, self.width - 6) if idx == self.selected_index: @@ -1150,12 +1241,26 @@ class TUI: self.stdscr.addstr(current_y, 2, f"Hash: {hash_display}", curses.A_DIM) current_y += 1 - # Signature + # Signature and verification status if self.current_note.signature: - self.stdscr.addstr(current_y, 2, "Signature: [GPG signed]", curses.color_pair(2)) + verified, info = self.current_note.verify_signature() + if verified: + sig_display = f"Signature: ✓ Verified ({info})" + self.stdscr.addstr(current_y, 2, sig_display, curses.color_pair(2)) + else: + if info == "unsigned": + sig_display = "Signature: ? Unsigned" + self.stdscr.addstr(current_y, 2, sig_display, curses.color_pair(3)) + else: + sig_display = f"Signature: ✗ Failed ({info})" + self.stdscr.addstr(current_y, 2, sig_display, curses.color_pair(4)) + current_y += 1 + else: + # No signature present + self.stdscr.addstr(current_y, 2, "Signature: ? Unsigned", curses.color_pair(3)) current_y += 1 - self.stdscr.addstr(self.height - 3, 2, "[d] Delete [b] Back", curses.color_pair(3)) + self.stdscr.addstr(self.height - 3, 2, "[d] Delete [b] Back [v] Verify", curses.color_pair(3)) def draw_help(self): """Draw the help screen with keyboard shortcuts and features""" @@ -1633,6 +1738,10 @@ class TUI: self.view_evidence_notes() else: self.view_evidence_notes() + elif self.current_view == "note_detail": + # Verify signature in note detail view + if self.current_note: + self.verify_note_signature() # Delete elif key == ord('d'):