Add GPG signature verification and first-run setup wizard

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
This commit is contained in:
Claude
2025-12-13 19:28:01 +00:00
parent 6e4bb9b265
commit 96309319b9
5 changed files with 356 additions and 8 deletions

View File

@@ -161,6 +161,10 @@ def main():
quick_add_note(args.note) quick_add_note(args.note)
return 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) # Launch TUI (with optional direct navigation to active context)
try: try:
from .tui_app import run_tui from .tui_app import run_tui

View File

@@ -2,6 +2,93 @@ import subprocess
import hashlib import hashlib
class Crypto: 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 @staticmethod
def list_gpg_keys(): def list_gpg_keys():
""" """

131
trace/gpg_wizard.py Normal file
View File

@@ -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

View File

@@ -4,7 +4,7 @@ import time
import hashlib import hashlib
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List, Optional, Dict from typing import List, Optional, Dict, Tuple
from .extractors import TagExtractor, IOCExtractor from .extractors import TagExtractor, IOCExtractor
@@ -32,6 +32,23 @@ class Note:
data = f"{self.timestamp}:{self.content}".encode('utf-8') data = f"{self.timestamp}:{self.content}".encode('utf-8')
self.content_hash = hashlib.sha256(data).hexdigest() 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 @staticmethod
def extract_iocs_from_text(text): def extract_iocs_from_text(text):
"""Extract IOCs from text and return as list of (ioc, type) tuples""" """Extract IOCs from text and return as list of (ioc, type) tuples"""

View File

@@ -118,6 +118,73 @@ class TUI:
self.flash_message = msg self.flash_message = msg
self.flash_time = time.time() 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): def _save_nav_position(self):
"""Save current navigation position before changing views""" """Save current navigation position before changing views"""
# Create a key based on current view and context # Create a key based on current view and context
@@ -291,6 +358,22 @@ class TUI:
return ellipsis[:max_width] 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): def _display_line_with_highlights(self, y, x_start, line, is_selected=False, win=None):
""" """
Display a line with intelligent highlighting. Display a line with intelligent highlighting.
@@ -823,7 +906,9 @@ class TUI:
# Format note content # Format note content
note_content = note.content.replace('\n', ' ').replace('\r', ' ') 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_str = self._safe_truncate(display_str, self.width - 6)
# Display with smart highlighting (IOCs take priority over selection) # Display with smart highlighting (IOCs take priority over selection)
@@ -888,7 +973,9 @@ class TUI:
note = notes[idx] note = notes[idx]
# Replace newlines with spaces for single-line display # Replace newlines with spaces for single-line display
note_content = note.content.replace('\n', ' ').replace('\r', ' ') 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 # Truncate safely for Unicode
display_str = self._safe_truncate(display_str, self.width - 6) display_str = self._safe_truncate(display_str, self.width - 6)
@@ -982,7 +1069,9 @@ class TUI:
if len(content_preview) > 50: if len(content_preview) > 50:
content_preview = 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) display_str = self._safe_truncate(display_str, self.width - 6)
if idx == self.selected_index: if idx == self.selected_index:
@@ -1080,7 +1169,9 @@ class TUI:
timestamp_str = time.ctime(note.timestamp) timestamp_str = time.ctime(note.timestamp)
content_preview = note.content[:60].replace('\n', ' ') + "..." if len(note.content) > 60 else note.content.replace('\n', ' ') 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) display_str = self._safe_truncate(display_str, self.width - 6)
if idx == self.selected_index: if idx == self.selected_index:
@@ -1150,12 +1241,26 @@ class TUI:
self.stdscr.addstr(current_y, 2, f"Hash: {hash_display}", curses.A_DIM) self.stdscr.addstr(current_y, 2, f"Hash: {hash_display}", curses.A_DIM)
current_y += 1 current_y += 1
# Signature # Signature and verification status
if self.current_note.signature: 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 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): def draw_help(self):
"""Draw the help screen with keyboard shortcuts and features""" """Draw the help screen with keyboard shortcuts and features"""
@@ -1633,6 +1738,10 @@ class TUI:
self.view_evidence_notes() self.view_evidence_notes()
else: else:
self.view_evidence_notes() 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 # Delete
elif key == ord('d'): elif key == ord('d'):