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. |
|
| **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.
|
||||||
|
|||||||
143
trace/cli.py
143
trace/cli.py
@@ -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
|
||||||
|
|||||||
@@ -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
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 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"""
|
||||||
|
|||||||
140
trace/tui_app.py
140
trace/tui_app.py
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user