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

View File

@@ -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():
"""

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

View File

@@ -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'):