mirror of
https://github.com/overcuriousity/trace.git
synced 2025-12-20 13:02:21 +00:00
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:
175
README.md
175
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 <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.
|
||||
|
||||
113
trace/cli.py
113
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:
|
||||
@@ -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
|
||||
|
||||
@@ -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
131
trace/gpg_wizard.py
Normal 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
|
||||
@@ -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"""
|
||||
|
||||
140
trace/tui_app.py
140
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"""
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user