From 96309319b9c3e3b3a12113ba71fa14e467ecaf54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Dec 2025 19:28:01 +0000 Subject: [PATCH 1/3] 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'): From 9248799e79850653b5b774e68f2c2364b7f8b9c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Dec 2025 19:42:12 +0000 Subject: [PATCH 2/3] Add GPG signing for entire markdown exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When exporting to markdown (--export), the entire export document is now signed with GPG if signing is enabled in settings. Features: - Builds export content in memory before signing - Signs the complete document as one GPG clearsigned block - Individual note signatures are preserved within the export - Provides two layers of verification: 1. Document-level: Verifies entire export hasn't been modified 2. Note-level: Verifies individual notes haven't been tampered with Verification workflow: - Entire export: gpg --verify export.md - Individual notes: Extract signature blocks and verify separately Changes: - Renamed write_note() to format_note_for_export() returning string - Export content built in memory before file write - Signs complete export if pgp_enabled=True - Shows verification instructions after successful export Example output: ✓ Export signed with GPG ✓ Exported to case-2024-001.md To verify the export: gpg --verify case-2024-001.md --- trace/cli.py | 134 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 86 insertions(+), 48 deletions(-) diff --git a/trace/cli.py b/trace/cli.py index 46ff267..4eff4b2 100644 --- a/trace/cli.py +++ b/trace/cli.py @@ -79,68 +79,106 @@ def quick_add_note(content: str): def export_markdown(output_file: str = "export.md"): try: storage = Storage() + state_manager = StateManager() + settings = state_manager.get_settings() + # Build the export content in memory first + content_lines = [] + content_lines.append("# Forensic Notes Export\n\n") + content_lines.append(f"Generated on: {time.ctime()}\n\n") + + for case in storage.cases: + content_lines.append(f"## Case: {case.case_number}\n") + if case.name: + content_lines.append(f"**Name:** {case.name}\n") + if case.investigator: + content_lines.append(f"**Investigator:** {case.investigator}\n") + content_lines.append(f"**Case ID:** {case.case_id}\n\n") + + content_lines.append("### Case Notes\n") + if not case.notes: + content_lines.append("_No notes._\n") + for note in case.notes: + note_content = format_note_for_export(note) + content_lines.append(note_content) + + content_lines.append("\n### Evidence\n") + if not case.evidence: + content_lines.append("_No evidence._\n") + + for ev in case.evidence: + content_lines.append(f"#### Evidence: {ev.name}\n") + if ev.description: + content_lines.append(f"_{ev.description}_\n") + content_lines.append(f"**ID:** {ev.evidence_id}\n") + + # Include source hash if available + source_hash = ev.metadata.get("source_hash") + if source_hash: + content_lines.append(f"**Source Hash:** `{source_hash}`\n") + content_lines.append("\n") + + content_lines.append("##### Evidence Notes\n") + if not ev.notes: + content_lines.append("_No notes._\n") + for note in ev.notes: + note_content = format_note_for_export(note) + content_lines.append(note_content) + content_lines.append("\n") + content_lines.append("---\n\n") + + # Join all content + export_content = "".join(content_lines) + + # Sign the entire export if GPG is enabled + if settings.get("pgp_enabled", False): + gpg_key_id = settings.get("gpg_key_id", None) + signed_export = Crypto.sign_content(export_content, key_id=gpg_key_id) + + if signed_export: + # Write the signed version + final_content = signed_export + print(f"✓ Export signed with GPG") + else: + # Signing failed - write unsigned + final_content = export_content + print("⚠ Warning: GPG signing failed. Export saved unsigned.", file=sys.stderr) + else: + final_content = export_content + + # Write to file with open(output_file, "w", encoding='utf-8') as f: - f.write("# Forensic Notes Export\n\n") - f.write(f"Generated on: {time.ctime()}\n\n") + f.write(final_content) - for case in storage.cases: - f.write(f"## Case: {case.case_number}\n") - if case.name: - f.write(f"**Name:** {case.name}\n") - if case.investigator: - f.write(f"**Investigator:** {case.investigator}\n") - f.write(f"**Case ID:** {case.case_id}\n\n") + print(f"✓ Exported to {output_file}") - f.write("### Case Notes\n") - if not case.notes: - f.write("_No notes._\n") - for note in case.notes: - write_note(f, note) + # Show verification instructions + if settings.get("pgp_enabled", False) and signed_export: + print(f"\nTo verify the export:") + print(f" gpg --verify {output_file}") - f.write("\n### Evidence\n") - if not case.evidence: - f.write("_No evidence._\n") - - for ev in case.evidence: - f.write(f"#### Evidence: {ev.name}\n") - if ev.description: - f.write(f"_{ev.description}_\n") - f.write(f"**ID:** {ev.evidence_id}\n") - - # Include source hash if available - source_hash = ev.metadata.get("source_hash") - if source_hash: - f.write(f"**Source Hash:** `{source_hash}`\n") - f.write("\n") - - f.write("##### Evidence Notes\n") - if not ev.notes: - f.write("_No notes._\n") - for note in ev.notes: - write_note(f, note) - f.write("\n") - f.write("---\n\n") - print(f"Exported to {output_file}") except (IOError, OSError, PermissionError) as e: print(f"Error: Failed to export to {output_file}: {e}") sys.exit(1) -def write_note(f, note: Note): - f.write(f"- **{time.ctime(note.timestamp)}**\n") - f.write(f" - Content:\n") +def format_note_for_export(note: Note) -> str: + """Format a single note for export (returns string instead of writing to file)""" + lines = [] + lines.append(f"- **{time.ctime(note.timestamp)}**\n") + lines.append(f" - Content:\n") # Properly indent multi-line content for line in note.content.splitlines(): - f.write(f" {line}\n") - f.write(f" - Hash: `{note.content_hash}`\n") + lines.append(f" {line}\n") + lines.append(f" - Hash: `{note.content_hash}`\n") if note.signature: - f.write(" - **Signature Verified:**\n") - f.write(" ```\n") + lines.append(" - **Individual Note Signature:**\n") + lines.append(" ```\n") # Indent signature for markdown block for line in note.signature.splitlines(): - f.write(f" {line}\n") - f.write(" ```\n") - f.write("\n") + lines.append(f" {line}\n") + lines.append(" ```\n") + lines.append("\n") + return "".join(lines) def main(): parser = argparse.ArgumentParser(description="trace: Forensic Note Taking Tool") From 90a82dc0d38e581cca4b165a9feb40f3166c0196 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Dec 2025 21:15:58 +0000 Subject: [PATCH 3/3] Refactor note signing: sign hash only + comprehensive documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed the cryptographic signing approach to be more efficient and standard: **Signing Logic Changes:** 1. **Note-level signing** (CLI & TUI): - Old: Sign "Hash: {hash}\nContent: {content}" - New: Sign only the SHA256 hash - Rationale: Hash already proves integrity (timestamp+content), signature proves authenticity. More efficient, standard approach. 2. **Export-level signing** (unchanged): - Entire markdown export is GPG-signed - Provides document-level integrity verification **Implementation:** - trace/cli.py: Updated quick_add_note() to sign hash only - trace/tui_app.py: Updated note creation dialog to sign hash only - Updated export format labels to clarify what's being signed: "SHA256 Hash (timestamp:content)" and "GPG Signature of Hash" **Documentation (NEW):** Added comprehensive "Cryptographic Integrity & Chain of Custody" section to README.md explaining: - Layer 1: Note-level integrity (hash + signature) - Layer 2: Export-level integrity (document signature) - First-run GPG setup wizard - Internal verification workflow (TUI symbols: ✓/✗/?) - External verification workflow (court/auditor use case) - Step-by-step verification instructions - Cryptographic trust model diagram - Security considerations and limitations Added "CRYPTOGRAPHIC INTEGRITY" section to in-app help (press ?): - Explains dual-layer signing approach - Shows verification symbol meanings - Documents 'v' key for verification details - External verification command **Verification Workflow:** 1. Investigator: trace --export + gpg --armor --export 2. Recipient: gpg --import pubkey.asc 3. Document: gpg --verify export.md 4. Individual notes: Extract signature blocks and verify Files modified: - README.md: +175 lines of documentation - trace/cli.py: Sign hash only, update labels - trace/tui_app.py: Sign hash only, add help section --- README.md | 175 +++++++++++++++++++++++++++++++++++++++++++++++ trace/cli.py | 9 +-- trace/tui_app.py | 17 ++++- 3 files changed, 194 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 590d19d..3d8c2de 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,181 @@ After this, you can log with just: `t "Your note here"` | **Tag System** | Supports `#hashtags` for classification and filtering. | **Efficient triage** of large log sets. | | **Minimal Footprint** | Built solely on Python standard library modules. | **Maximum portability** on restricted forensic environments. | +## Cryptographic Integrity & Chain of Custody + +`trace` implements a dual-layer cryptographic system designed for legal admissibility and forensic integrity: + +### Layer 1: Note-Level Integrity (Always Active) + +**Process:** +1. **Timestamp Generation** - Precise Unix timestamp captured at note creation +2. **Content Hashing** - SHA256 hash computed from `timestamp:content` +3. **Optional Signature** - Hash is signed with investigator's GPG private key + +**Mathematical Representation:** +``` +hash = SHA256(timestamp + ":" + content) +signature = GPG_Sign(hash, private_key) +``` + +**Security Properties:** +- **Temporal Integrity**: Timestamp is cryptographically bound to content (cannot backdate notes) +- **Tamper Detection**: Any modification to content or timestamp invalidates the hash +- **Non-Repudiation**: GPG signature proves who created the note (if signing enabled) +- **Efficient Storage**: Signing only the hash (64 hex chars) instead of full content + +### Layer 2: Export-Level Integrity (On Demand) + +When exporting to markdown (`--export`), the **entire export document** is GPG-signed if signing is enabled. + +**Process:** +1. Generate complete markdown export with all cases, evidence, and notes +2. Individual note signatures are preserved within the export +3. Entire document is clearsigned with GPG + +**Security Properties:** +- **Document Integrity**: Proves export hasn't been modified after generation +- **Dual Verification**: Both individual notes AND complete document can be verified +- **Chain of Custody**: Establishes provenance from evidence collection through report generation + +### First-Run GPG Setup + +On first launch, `trace` runs an interactive wizard to configure GPG signing: + +1. **GPG Detection** - Checks if GPG is installed (gracefully continues without if missing) +2. **Key Selection** - Lists available secret keys from your GPG keyring +3. **Configuration** - Saves selected key ID to `~/.trace/settings.json` + +**If GPG is not available:** +- Application continues to function normally +- Notes are hashed (SHA256) but not signed +- You can enable GPG later by editing `~/.trace/settings.json` + +### Verification Workflows + +#### Internal Verification (Within trace TUI) + +The TUI automatically verifies signatures and displays status symbols: +- `✓` - Signature verified with public key in keyring +- `✗` - Signature verification failed (tampered or missing key) +- `?` - Note is unsigned + +**To verify a specific note:** +1. Navigate to the note in TUI +2. Press `Enter` to view note details +3. Press `v` to see detailed verification information + +#### External Verification (Manual/Court) + +**Scenario**: Forensic investigator sends evidence to court/auditor + +**Step 1 - Investigator exports evidence:** +```bash +# Export all notes with signatures +trace --export --output investigation-2024-001.md + +# Export public key for verification +gpg --armor --export investigator@agency.gov > investigator-pubkey.asc + +# Send both files to recipient +``` + +**Step 2 - Recipient verifies document:** +```bash +# Import investigator's public key +gpg --import investigator-pubkey.asc + +# Verify entire export document +gpg --verify investigation-2024-001.md +``` + +**Expected output if valid:** +``` +gpg: Signature made Mon Dec 13 14:23:45 2024 +gpg: using RSA key ABC123DEF456 +gpg: Good signature from "John Investigator " +``` + +**Step 3 - Verify individual notes (optional):** + +Individual note signatures are embedded in the markdown export. To verify a specific note: + +1. Open `investigation-2024-001.md` in a text editor +2. Locate the note's signature block: + ``` + - **GPG Signature of Hash:** + ``` + -----BEGIN PGP SIGNED MESSAGE----- + Hash: SHA256 + + a3f5b2c8d9e1f4a7b6c3d8e2f5a9b4c7d1e6f3a8b5c2d9e4f7a1b8c6d3e0f5a2 + -----BEGIN PGP SIGNATURE----- + ... + -----END PGP SIGNATURE----- + ``` +3. Extract the signature block (from `-----BEGIN PGP SIGNED MESSAGE-----` to `-----END PGP SIGNATURE-----`) +4. Save to a file and verify: + ```bash + cat > note-signature.txt + + Ctrl+D + + gpg --verify note-signature.txt + ``` + +**What gets verified:** +- The SHA256 hash proves the note content and timestamp haven't changed +- The GPG signature proves who created that hash +- Together: Proves this specific content was created by this investigator at this time + +### Cryptographic Trust Model + +``` +┌─────────────────────────────────────────────────────────┐ +│ Note Creation (Investigator) │ +├─────────────────────────────────────────────────────────┤ +│ 1. Content: "Malware detected on host-192.168.1.50" │ +│ 2. Timestamp: 1702483425.123456 │ +│ 3. Hash: SHA256(timestamp:content) │ +│ → a3f5b2c8d9e1f4a7b6c3d8e2f5a9b4c7... │ +│ 4. Signature: GPG_Sign(hash, private_key) │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Export Generation │ +├─────────────────────────────────────────────────────────┤ +│ 1. Build markdown with all notes + individual sigs │ +│ 2. Sign entire document: GPG_Sign(document) │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Verification (Court/Auditor) │ +├─────────────────────────────────────────────────────────┤ +│ 1. Import investigator's public key │ +│ 2. Verify document signature → Proves export integrity │ +│ 3. Verify individual notes → Proves note authenticity │ +│ 4. Recompute hashes → Proves content hasn't changed │ +└─────────────────────────────────────────────────────────┘ +``` + +### Security Considerations + +**What is protected:** +- ✓ Content integrity (hash detects any modification) +- ✓ Temporal integrity (timestamp cryptographically bound) +- ✓ Attribution (signature proves who created it) +- ✓ Export completeness (document signature proves no additions/removals) + +**What is NOT protected:** +- ✗ Note deletion (signatures can't prevent removal from database) +- ✗ Selective disclosure (investigator can choose which notes to export) +- ✗ Sequential ordering (signatures are per-note, not chained) + +**Trust Dependencies:** +- You must trust the investigator's GPG key (verify fingerprint out-of-band) +- You must trust the investigator's system clock was accurate +- You must trust the investigator didn't destroy contradictory evidence + ## TUI Reference (Management Console) Execute `trace` (no arguments) to enter the Text User Interface. This environment is used for setup, review, and reporting. diff --git a/trace/cli.py b/trace/cli.py index 4eff4b2..821d09b 100644 --- a/trace/cli.py +++ b/trace/cli.py @@ -49,12 +49,13 @@ def quick_add_note(content: str): note.extract_tags() # Extract hashtags from content note.extract_iocs() # Extract IOCs from content - # Try signing if enabled + # Try signing the hash if enabled signature = None if settings.get("pgp_enabled", True): gpg_key_id = settings.get("gpg_key_id", None) if gpg_key_id: - signature = Crypto.sign_content(f"Hash: {note.content_hash}\nContent: {note.content}", key_id=gpg_key_id) + # Sign only the hash (hash already includes timestamp:content for integrity) + signature = Crypto.sign_content(note.content_hash, key_id=gpg_key_id) if signature: note.signature = signature else: @@ -169,9 +170,9 @@ def format_note_for_export(note: Note) -> str: # Properly indent multi-line content for line in note.content.splitlines(): lines.append(f" {line}\n") - lines.append(f" - Hash: `{note.content_hash}`\n") + lines.append(f" - SHA256 Hash (timestamp:content): `{note.content_hash}`\n") if note.signature: - lines.append(" - **Individual Note Signature:**\n") + lines.append(" - **GPG Signature of Hash:**\n") lines.append(" ```\n") # Indent signature for markdown block for line in note.signature.splitlines(): diff --git a/trace/tui_app.py b/trace/tui_app.py index ed4ae64..7af98ae 100644 --- a/trace/tui_app.py +++ b/trace/tui_app.py @@ -1346,12 +1346,22 @@ class TUI: help_lines.append((" Highlighted in red in full note views", curses.A_DIM)) help_lines.append((" Note Navigation Press Enter on any note to view with highlighting", curses.A_NORMAL)) help_lines.append((" Selected note auto-centered and highlighted", curses.A_DIM)) - help_lines.append((" Integrity All notes SHA256 hashed + optional GPG signing", curses.A_NORMAL)) - help_lines.append((" GPG Settings Press 's' to toggle signing & select GPG key", curses.A_NORMAL)) help_lines.append((" Source Hash Store evidence file hashes for chain of custody", curses.A_NORMAL)) help_lines.append((" Export Run: trace --export --output report.md", curses.A_DIM)) help_lines.append(("", curses.A_NORMAL)) + # Cryptographic Integrity + help_lines.append(("CRYPTOGRAPHIC INTEGRITY", curses.A_BOLD | curses.color_pair(2))) + help_lines.append((" Layer 1: Notes SHA256(timestamp:content) proves integrity", curses.A_NORMAL)) + help_lines.append((" GPG signature of hash proves authenticity", curses.A_DIM)) + help_lines.append((" Layer 2: Export Entire export document GPG-signed", curses.A_NORMAL)) + help_lines.append((" Dual verification: individual + document level", curses.A_DIM)) + help_lines.append((" Verification ✓=verified ✗=failed ?=unsigned", curses.A_NORMAL)) + help_lines.append((" Press 'v' on note detail for verification info", curses.A_DIM)) + help_lines.append((" GPG Settings Press 's' to toggle signing & select GPG key", curses.A_NORMAL)) + help_lines.append((" External Verify gpg --verify exported-file.md", curses.A_DIM)) + help_lines.append(("", curses.A_NORMAL)) + # Data Location help_lines.append(("DATA STORAGE", curses.A_BOLD | curses.color_pair(2))) help_lines.append((" All data: ~/.trace/data.json", curses.A_NORMAL)) @@ -2622,7 +2632,8 @@ class TUI: signed = False if pgp_enabled: - sig = Crypto.sign_content(f"Hash: {note.content_hash}\nContent: {note.content}", key_id=gpg_key_id or "") + # Sign only the hash (hash already includes timestamp:content for integrity) + sig = Crypto.sign_content(note.content_hash, key_id=gpg_key_id or "") if sig: note.signature = sig signed = True