Merge pull request #10 from overcuriousity/claude/add-gpg-verification-01CeuKD7An97D9W83Dg3QxKe

Claude/add gpg verification 01 ceu kd7 an97 d9 w83 dg3 qx ke
This commit is contained in:
overcuriousity
2025-12-14 02:40:45 +01:00
committed by GitHub
6 changed files with 634 additions and 61 deletions

175
README.md
View File

@@ -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 <investigator@agency.gov>"
```
**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
<paste signature block>
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.

View File

@@ -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:
@@ -79,68 +80,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()
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")
# 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:
f.write(f"## Case: {case.case_number}\n")
content_lines.append(f"## Case: {case.case_number}\n")
if case.name:
f.write(f"**Name:** {case.name}\n")
content_lines.append(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")
content_lines.append(f"**Investigator:** {case.investigator}\n")
content_lines.append(f"**Case ID:** {case.case_id}\n\n")
f.write("### Case Notes\n")
content_lines.append("### Case Notes\n")
if not case.notes:
f.write("_No notes._\n")
content_lines.append("_No notes._\n")
for note in case.notes:
write_note(f, note)
note_content = format_note_for_export(note)
content_lines.append(note_content)
f.write("\n### Evidence\n")
content_lines.append("\n### Evidence\n")
if not case.evidence:
f.write("_No evidence._\n")
content_lines.append("_No evidence._\n")
for ev in case.evidence:
f.write(f"#### Evidence: {ev.name}\n")
content_lines.append(f"#### Evidence: {ev.name}\n")
if ev.description:
f.write(f"_{ev.description}_\n")
f.write(f"**ID:** {ev.evidence_id}\n")
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:
f.write(f"**Source Hash:** `{source_hash}`\n")
f.write("\n")
content_lines.append(f"**Source Hash:** `{source_hash}`\n")
content_lines.append("\n")
f.write("##### Evidence Notes\n")
content_lines.append("##### Evidence Notes\n")
if not ev.notes:
f.write("_No notes._\n")
content_lines.append("_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}")
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(final_content)
print(f"✓ Exported to {output_file}")
# Show verification instructions
if settings.get("pgp_enabled", False) and signed_export:
print(f"\nTo verify the export:")
print(f" gpg --verify {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" - SHA256 Hash (timestamp:content): `{note.content_hash}`\n")
if note.signature:
f.write(" - **Signature Verified:**\n")
f.write(" ```\n")
lines.append(" - **GPG Signature of Hash:**\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")
@@ -161,6 +200,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"""
@@ -1241,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))
@@ -1633,6 +1748,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'):
@@ -2513,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