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. | | **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. | | **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) ## TUI Reference (Management Console)
Execute `trace` (no arguments) to enter the Text User Interface. This environment is used for setup, review, and reporting. 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_tags() # Extract hashtags from content
note.extract_iocs() # Extract IOCs from content note.extract_iocs() # Extract IOCs from content
# Try signing if enabled # Try signing the hash if enabled
signature = None signature = None
if settings.get("pgp_enabled", True): if settings.get("pgp_enabled", True):
gpg_key_id = settings.get("gpg_key_id", None) gpg_key_id = settings.get("gpg_key_id", None)
if gpg_key_id: 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: if signature:
note.signature = signature note.signature = signature
else: else:
@@ -79,68 +80,106 @@ def quick_add_note(content: str):
def export_markdown(output_file: str = "export.md"): def export_markdown(output_file: str = "export.md"):
try: try:
storage = Storage() 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: with open(output_file, "w", encoding='utf-8') as f:
f.write("# Forensic Notes Export\n\n") f.write(final_content)
f.write(f"Generated on: {time.ctime()}\n\n")
for case in storage.cases: print(f"✓ Exported to {output_file}")
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")
f.write("### Case Notes\n") # Show verification instructions
if not case.notes: if settings.get("pgp_enabled", False) and signed_export:
f.write("_No notes._\n") print(f"\nTo verify the export:")
for note in case.notes: print(f" gpg --verify {output_file}")
write_note(f, note)
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: except (IOError, OSError, PermissionError) as e:
print(f"Error: Failed to export to {output_file}: {e}") print(f"Error: Failed to export to {output_file}: {e}")
sys.exit(1) sys.exit(1)
def write_note(f, note: Note): def format_note_for_export(note: Note) -> str:
f.write(f"- **{time.ctime(note.timestamp)}**\n") """Format a single note for export (returns string instead of writing to file)"""
f.write(f" - Content:\n") lines = []
lines.append(f"- **{time.ctime(note.timestamp)}**\n")
lines.append(f" - Content:\n")
# Properly indent multi-line content # Properly indent multi-line content
for line in note.content.splitlines(): for line in note.content.splitlines():
f.write(f" {line}\n") lines.append(f" {line}\n")
f.write(f" - Hash: `{note.content_hash}`\n") lines.append(f" - SHA256 Hash (timestamp:content): `{note.content_hash}`\n")
if note.signature: if note.signature:
f.write(" - **Signature Verified:**\n") lines.append(" - **GPG Signature of Hash:**\n")
f.write(" ```\n") lines.append(" ```\n")
# Indent signature for markdown block # Indent signature for markdown block
for line in note.signature.splitlines(): for line in note.signature.splitlines():
f.write(f" {line}\n") lines.append(f" {line}\n")
f.write(" ```\n") lines.append(" ```\n")
f.write("\n") lines.append("\n")
return "".join(lines)
def main(): def main():
parser = argparse.ArgumentParser(description="trace: Forensic Note Taking Tool") parser = argparse.ArgumentParser(description="trace: Forensic Note Taking Tool")
@@ -161,6 +200,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"""
@@ -1241,12 +1346,22 @@ class TUI:
help_lines.append((" Highlighted in red in full note views", curses.A_DIM)) 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((" 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((" 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((" 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((" Export Run: trace --export --output report.md", curses.A_DIM))
help_lines.append(("", curses.A_NORMAL)) 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 # Data Location
help_lines.append(("DATA STORAGE", curses.A_BOLD | curses.color_pair(2))) help_lines.append(("DATA STORAGE", curses.A_BOLD | curses.color_pair(2)))
help_lines.append((" All data: ~/.trace/data.json", curses.A_NORMAL)) help_lines.append((" All data: ~/.trace/data.json", curses.A_NORMAL))
@@ -1633,6 +1748,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'):
@@ -2513,7 +2632,8 @@ class TUI:
signed = False signed = False
if pgp_enabled: 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: if sig:
note.signature = sig note.signature = sig
signed = True signed = True