18 Commits

Author SHA1 Message Date
overcuriousity
15bc00a195 Merge pull request #24 from overcuriousity/claude/clarify-timestamp-format-cBGwJ
Claude/clarify timestamp format c b gw j
2025-12-14 21:57:30 +01:00
Claude
053369df78 Add Unix timestamp to TUI exports for hash reproducibility
The TUI export handler (_write_note_markdown) was missing the Unix timestamp
that was added to CLI exports. This ensures consistency across all export paths.

Changes:
- Updated _write_note_markdown() in trace/tui/handlers/export_handler.py
- Now includes "Unix Timestamp: `{timestamp}` (for hash verification)" line
- Matches the format from CLI exports in trace/cli.py
- Multi-line content is properly indented
- Hash label updated to "SHA256 Hash (timestamp:content)" for clarity

All export paths (CLI --export, TUI case export, TUI evidence export) now
include the Unix timestamp needed for independent hash verification.
2025-12-14 20:49:36 +00:00
Claude
eca56c0d54 Add Unix timestamps to exports and clarify format in README
Changes:
- Modified export format to include Unix timestamp for hash reproducibility
  - Each note now shows: "Unix Timestamp: `{timestamp}` (for hash verification)"
  - This allows independent verification using: SHA256("{timestamp}:{content}")

- Updated README.md with comprehensive timestamp format documentation:
  - Clarified that timestamps are Unix epoch (seconds since 1970-01-01 UTC) as float
  - Added example: 1702345678.123456
  - Documented exact hash input format: "{timestamp}:{content}"
  - Added "Hash Verification (Manual)" section with step-by-step verification instructions
  - Included examples using Python and command-line tools
  - Updated Core Features table with timestamp format details
  - Enhanced Layer 1 integrity documentation with concrete examples

These changes ensure hash reproducibility from exported markdown files,
critical for forensic chain of custody and independent verification.
2025-12-14 20:47:50 +00:00
Claude
06b7680982 Clarify timestamp format used in hash calculations
Added comprehensive documentation to make it crystal clear that:
- Timestamps are Unix epoch timestamps (seconds since 1970-01-01 00:00:00 UTC) stored as floats
- Hash input format is "{timestamp}:{content}" with float-to-string conversion
- Example: "1702345678.123456:Suspicious process detected"
- Full float precision is preserved, ensuring forensic tamper-evidence

Updated documentation in:
- trace/models/__init__.py: Added field comments and detailed docstring for calculate_hash()
- trace/crypto.py: Added comprehensive docstring for hash_content() with examples
- CLAUDE.md: Added detailed explanation in Integrity System section
2025-12-14 20:45:22 +00:00
overcuriousity
bfefb42761 Merge pull request #23 from overcuriousity/claude/show-pgp-signature-llNQW
Display raw PGP signature in verification dialog
2025-12-14 20:51:41 +01:00
Claude
070e76467c Display raw PGP signature in verification dialog
Simplified approach: When pressing 'V' in note details, the TUI temporarily
exits to terminal mode and prints the raw PGP signature directly to stdout.

This allows users to:
- Select and copy the signature using their terminal's native copy/paste
- Works with any shell (bash, fish, zsh, etc.)
- No dependency on clipboard tools (xclip, xsel, pbcopy, clip)
- No complex shell-specific commands needed

The signature is displayed with verification status and clear formatting,
then waits for Enter to return to the TUI.

This is the simplest and most universal solution for viewing and copying
PGP signatures for external verification in Kleopatra or GPG tools.
2025-12-14 19:50:56 +00:00
overcuriousity
b80dd10901 Merge pull request #22 from overcuriousity/claude/show-pgp-signature-llNQW
Add clear clipboard feedback and GPG verification commands
2025-12-14 20:45:40 +01:00
Claude
fe3c0710c6 Add clear clipboard feedback and GPG verification commands
Enhanced the signature verification dialog with:

1. Clear clipboard status feedback:
   - Shows success: "✓ Clipboard: Copied successfully (using xclip)"
   - Shows failure: "✗ Clipboard: Failed to copy"
   - Provides installation instructions for Linux (xclip/xsel)

2. Direct GPG verification commands:
   - Linux/macOS: gpg --verify <(cat ~/.trace/last_signature.txt)
   - Windows PowerShell: Get-Content ~/.trace/last_signature.txt | gpg --verify
   - Also includes simple view commands (cat/Get-Content)

3. Better organized dialog sections:
   - EXPORT STATUS section showing clipboard and file status
   - VERIFY WITH GPG section with platform-specific commands

Users now get immediate, clear feedback about whether clipboard copy worked
and can easily copy/paste the verification commands to verify signatures
externally in Kleopatra or command-line GPG tools.
2025-12-14 19:45:00 +00:00
overcuriousity
809a4a498f Merge pull request #21 from overcuriousity/claude/show-pgp-signature-llNQW
Auto-export PGP signatures to clipboard and file
2025-12-14 20:41:47 +01:00
Claude
931e5debc8 Auto-export PGP signatures to clipboard and file
When pressing 'V' on a note detail view, the PGP signature is now:
1. Automatically copied to system clipboard (if available)
2. Saved to ~/.trace/last_signature.txt for manual access

This solves the copy/paste problem where signatures displayed in the TUI
modal couldn't be selected and copied. Users can now:
- Paste directly from clipboard into Kleopatra/GPG tools (if clipboard worked)
- Access the signature file from another terminal if needed

Platform support:
- Linux: Uses xclip or xsel (falls back to file if not available)
- macOS: Uses pbcopy (falls back to file if not available)
- Windows: Uses clip (falls back to file if not available)

The dialog shows clear feedback about:
- Whether clipboard copy succeeded
- File location and how to access it manually
- Verification status (verified/failed/unsigned)

No external dependencies added - uses only stdlib subprocess calls.
2025-12-14 19:40:25 +00:00
overcuriousity
f91f434f7f Merge pull request #20 from overcuriousity/claude/show-pgp-signature-llNQW
Display raw PGP signature in verification dialog
2025-12-14 20:34:56 +01:00
Claude
85ca483a1d Display raw PGP signature in verification dialog
Modified the verify_note_signature() function to show the complete raw PGP
signature text when users press 'V' in the note details view. This allows
users to copy/paste signatures into external tools like Kleopatra for
independent verification.

Changes:
- Added raw signature display after verification status info
- Implemented scrollable dialog with arrow keys and Page Up/Down support
- Added clear separator and label for the signature section
- Shows signature for verified, failed, and unsigned notes (when present)

Users can now easily copy PGP signatures for external verification workflows.
2025-12-14 19:33:52 +00:00
overcuriousity
f50fd1800d Merge pull request #19 from overcuriousity/claude/fix-unknown-signer-y3bWG
Add cross-platform compatibility documentation for GPG locale handling
2025-12-14 20:32:20 +01:00
Claude
b830d15d85 Add cross-platform compatibility documentation for GPG locale handling
Clarified how the locale override works across different platforms:

- Linux/macOS: LC_ALL and LANG variables control GPG output language
- Windows: GPG may ignore locale variables, but the code remains robust
  through explicit encoding='utf-8' and errors='replace' parameters

The combination of:
1. Setting LC_ALL/LANG to C.UTF-8 (works on Linux/macOS)
2. Explicit encoding='utf-8' parameter
3. errors='replace' for graceful handling

...ensures the code works reliably on all platforms even if the locale
setting is not fully respected by GPG.
2025-12-14 14:04:06 +00:00
overcuriousity
4a4e1e7c06 Merge pull request #18 from overcuriousity/claude/fix-unknown-signer-y3bWG
Fix UTF-8 decoding error when verifying signatures with international…
2025-12-14 15:02:01 +01:00
Claude
2a7d00d221 Fix UTF-8 decoding error when verifying signatures with international characters
Issue: GPG verification crashed with UnicodeDecodeError when signatures
contained international characters (German ö, Turkish ü, etc.) in the
signed content.

Error: "'utf-8' codec can't decode byte 0xf6 in position 160"

Root cause: subprocess.Popen was using default text decoding without
handling encoding errors gracefully.

Solution:
1. Changed LC_ALL/LANG from 'C' to 'C.UTF-8' to ensure GPG uses UTF-8
2. Added explicit encoding='utf-8' parameter to Popen
3. Added errors='replace' to replace invalid UTF-8 bytes instead of crashing

This allows the verification to proceed even if GPG's output contains
characters that don't decode cleanly, ensuring robustness with
multilingual content.
2025-12-14 14:01:03 +00:00
overcuriousity
c68fc66de6 Merge pull request #17 from overcuriousity/claude/fix-unknown-signer-y3bWG
Force English locale for GPG verify to fix localized output parsing
2025-12-14 14:56:49 +01:00
Claude
f68c8389da Force English locale for GPG verify to fix localized output parsing
Root cause: GPG outputs verification messages in the user's locale
(e.g., German "Gute Signatur von" instead of "Good signature from"),
causing the parsing logic to fail and display "Unknown signer".

Solution: Force LC_ALL=C and LANG=C environment variables when
calling 'gpg --verify' to ensure consistent English output across
all locales.

This ensures the code can reliably parse "Good signature from"
regardless of the user's system language settings.
2025-12-14 13:55:26 +00:00
7 changed files with 192 additions and 54 deletions

View File

@@ -145,6 +145,14 @@ The codebase is organized into focused, single-responsibility modules to make it
**Integrity System**: Every note automatically gets:
1. SHA256 hash of `timestamp:content` (via `Note.calculate_hash()`)
- **Timestamp Format**: Unix epoch timestamp as float (seconds since 1970-01-01 00:00:00 UTC)
- **Hash Input Format**: `"{timestamp}:{content}"` where timestamp is converted to string using Python's default str() conversion
- **Example**: For content "Suspicious process detected" with timestamp 1702345678.123456, the hash input is:
```
1702345678.123456:Suspicious process detected
```
- This ensures integrity of both WHAT was said (content) and WHEN it was said (timestamp)
- The exact float precision is preserved in the hash, making timestamps forensically tamper-evident
2. Optional GPG clearsign signature (if `pgp_enabled` in settings and GPG available)
**Tag System**: Regex-based hashtag extraction (`#word`)

View File

@@ -20,7 +20,7 @@ trace "IR team gained shell access. Initial persistence checks running."
trace "Observed outbound connection to 192.168.1.55 on port 80. #suspicious #network"
```
**System Integrity Chain:** Each command-line note is immediately stamped, concatenated with its content, and hashed using SHA256 before storage. This ensures a non-repudiable log entry.
**System Integrity Chain:** Each command-line note is immediately stamped with a Unix epoch timestamp (seconds since 1970-01-01 00:00:00 UTC as float, e.g., `1702345678.123456`), concatenated with its content in the format `"{timestamp}:{content}"`, and hashed using SHA256 before storage. This ensures a non-repudiable log entry with forensically tamper-evident timestamps.
## CLI Command Reference
@@ -195,7 +195,7 @@ After this, you can log with just: `t "Your note here"`
| Feature | Description | Operational Impact |
| :--- | :--- | :--- |
| **Integrity Hashing** | SHA256 applied to every log entry (content + timestamp). | **Guaranteed log integrity.** No modification possible post-entry. |
| **Integrity Hashing** | SHA256 applied to every log entry using format `"{unix_timestamp}:{content}"`. Timestamp is Unix epoch as float (e.g., `1702345678.123456`). | **Guaranteed log integrity.** No modification possible post-entry. Timestamps are forensically tamper-evident with full float precision. |
| **GPG Signing** | Optional PGP/GPG signature applied to notes. | **Non-repudiation** for formal evidence handling. |
| **IOC Extraction** | Automatic parsing of IPv4, FQDNs, URLs, hashes, and email addresses. | **Immediate intelligence gathering** from raw text. |
| **Tag System** | Supports `#hashtags` for classification and filtering. | **Efficient triage** of large log sets. |
@@ -208,20 +208,33 @@ After this, you can log with just: `t "Your note here"`
### 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`
1. **Timestamp Generation** - Precise Unix epoch timestamp (float) captured at note creation
- Format: Seconds since 1970-01-01 00:00:00 UTC (e.g., `1702345678.123456`)
- Full float precision preserved for forensic tamper-evidence
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)
timestamp = Unix epoch time as float (e.g., 1702345678.123456)
hash_input = "{timestamp}:{content}"
hash = SHA256(hash_input)
signature = GPG_Sign(hash, private_key)
```
**Example:**
```
Content: "Suspicious process detected"
Timestamp: 1702345678.123456
Hash input: "1702345678.123456:Suspicious process detected"
Hash: SHA256 of above = a3f5b2c8d9e1f4a7b6c3d8e2f5a9b4c7d1e6f3a8b5c2d9e4f7a1b8c6d3e0f5a2
```
**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)
- **Hash Reproducibility**: Exported markdown includes Unix timestamp for independent verification
- **Efficient Storage**: Signing only the hash (64 hex chars) instead of full content
### Layer 2: Export-Level Integrity (On Demand)
@@ -328,6 +341,26 @@ Individual note signatures are embedded in the markdown export. To verify a spec
- The GPG signature proves who created that hash
- Together: Proves this specific content was created by this investigator at this time
**Hash Verification (Manual):**
To independently verify a note's hash from the markdown export:
1. Locate the note in the export file and extract:
- Unix Timestamp (e.g., `1702345678.123456`)
- Content (e.g., `"Suspicious process detected"`)
- Claimed Hash (e.g., `a3f5b2c8...`)
2. Recompute the hash:
```bash
# Using Python
python3 -c "import hashlib; print(hashlib.sha256(b'1702345678.123456:Suspicious process detected').hexdigest())"
# Using command-line tools
echo -n "1702345678.123456:Suspicious process detected" | sha256sum
```
3. Compare the computed hash with the claimed hash - they must match exactly
### Cryptographic Trust Model
```

View File

@@ -364,9 +364,14 @@ def export_markdown(output_file: str = "export.md"):
sys.exit(1)
def format_note_for_export(note: Note) -> str:
"""Format a single note for export (returns string instead of writing to file)"""
"""Format a single note for export (returns string instead of writing to file)
Includes Unix timestamp for hash reproducibility - anyone can recompute the hash
using the formula: SHA256("{unix_timestamp}:{content}")
"""
lines = []
lines.append(f"- **{time.ctime(note.timestamp)}**\n")
lines.append(f" - Unix Timestamp: `{note.timestamp}` (for hash verification)\n")
lines.append(f" - Content:\n")
# Properly indent multi-line content
for line in note.content.splitlines():

View File

@@ -43,12 +43,25 @@ class Crypto:
return False, "Not a GPG signed message"
try:
# Force English output for consistent parsing across locales
# Linux/macOS: LC_ALL/LANG variables control GPG's output language
# Windows: GPG may ignore these, but encoding='utf-8' + errors='replace' provides robustness
import os
env = os.environ.copy()
# Use C.UTF-8 for English messages with UTF-8 encoding support
# Falls back gracefully via errors='replace' if locale not available
env['LC_ALL'] = 'C.UTF-8'
env['LANG'] = 'C.UTF-8'
proc = subprocess.Popen(
['gpg', '--verify'],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
text=True,
encoding='utf-8',
errors='replace', # Handle encoding issues on any platform
env=env
)
stdout, stderr = proc.communicate(input=signed_content, timeout=10)
@@ -171,5 +184,25 @@ class Crypto:
@staticmethod
def hash_content(content: str, timestamp: float) -> str:
"""Calculate SHA256 hash of timestamp:content.
Hash input format: "{timestamp}:{content}"
- timestamp: Unix epoch timestamp as float (seconds since 1970-01-01 00:00:00 UTC)
Example: 1702345678.123456
- The float is converted to string using Python's default str() conversion
- Colon (':') separator between timestamp and content
- Ensures integrity of both WHAT was said and WHEN it was said
Args:
content: The note content to hash
timestamp: Unix epoch timestamp as float
Returns:
SHA256 hash as hexadecimal string (64 characters)
Example:
>>> hash_content("Suspicious process detected", 1702345678.123456)
Computes SHA256 of: "1702345678.123456:Suspicious process detected"
"""
data = f"{timestamp}:{content}".encode('utf-8')
return hashlib.sha256(data).hexdigest()

View File

@@ -12,6 +12,9 @@ from .extractors import TagExtractor, IOCExtractor
@dataclass
class Note:
content: str
# Unix timestamp: seconds since 1970-01-01 00:00:00 UTC as float
# Example: 1702345678.123456
# This exact float value (with full precision) is used in hash calculation
timestamp: float = field(default_factory=time.time)
note_id: str = field(default_factory=lambda: str(uuid.uuid4()))
content_hash: str = ""
@@ -28,7 +31,16 @@ class Note:
self.iocs = IOCExtractor.extract_iocs(self.content)
def calculate_hash(self):
# We hash the content + timestamp to ensure integrity of 'when' it was said
"""Calculate SHA256 hash of timestamp:content.
Hash input format: "{timestamp}:{content}"
- timestamp: Unix epoch timestamp as float (e.g., "1702345678.123456")
- The float is converted to string using Python's default str() conversion
- Colon separator between timestamp and content
- Ensures integrity of both WHAT was said and WHEN it was said
Example hash input: "1702345678.123456:Suspicious process detected"
"""
data = f"{self.timestamp}:{self.content}".encode('utf-8')
self.content_hash = hashlib.sha256(data).hexdigest()

View File

@@ -222,15 +222,23 @@ class ExportHandler:
@staticmethod
def _write_note_markdown(f, note: Note):
"""Helper to write a note in markdown format"""
"""Helper to write a note in markdown format
Includes Unix timestamp for hash reproducibility - anyone can recompute the hash
using the formula: SHA256("{unix_timestamp}:{content}")
"""
f.write(f"- **{time.ctime(note.timestamp)}**\n")
f.write(f" - Content: {note.content}\n")
f.write(f" - Unix Timestamp: `{note.timestamp}` (for hash verification)\n")
f.write(f" - Content:\n")
# Properly indent multi-line content
for line in note.content.splitlines():
f.write(f" {line}\n")
if note.tags:
tags_str = " ".join([f"#{tag}" for tag in note.tags])
f.write(f" - Tags: {tags_str}\n")
f.write(f" - Hash: `{note.content_hash}`\n")
f.write(f" - SHA256 Hash (timestamp:content): `{note.content_hash}`\n")
if note.signature:
f.write(" - **Signature Verified:**\n")
f.write(" - **GPG Signature of Hash:**\n")
f.write(" ```\n")
for line in note.signature.splitlines():
f.write(f" {line}\n")

View File

@@ -119,13 +119,13 @@ class TUI:
self.flash_time = time.time()
def verify_note_signature(self):
"""Show detailed verification dialog for current note"""
"""Show signature verification and print raw signature to terminal"""
if not self.current_note:
return
verified, info = self.current_note.verify_signature()
# Prepare dialog content
# Handle unsigned notes
if not self.current_note.signature:
title = "Note Signature Status"
message = [
@@ -134,56 +134,95 @@ class TUI:
"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"
]
self._show_simple_dialog(title, message)
return
# Display dialog (reuse pattern from other dialogs)
# Temporarily exit curses to print signature to terminal
curses.endwin()
try:
# Print verification status
print("\n" + "=" * 70)
if verified:
print(f"✓ SIGNATURE VERIFIED - Signed by: {info}")
else:
print(f"✗ SIGNATURE VERIFICATION FAILED - Reason: {info}")
print("=" * 70)
print("\nRAW PGP SIGNATURE (select and copy from terminal):")
print("-" * 70)
# Print the actual signature
print(self.current_note.signature)
print("-" * 70)
print("\nPress Enter to return to trace...")
input()
finally:
# Restore curses mode
self.stdscr.refresh()
curses.doupdate()
def _show_simple_dialog(self, title, message_lines):
"""Display a simple scrollable dialog with the given title and message lines"""
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)
dialog_h = min(h - 4, len(message_lines) + 8)
dialog_w = min(w - 4, max(len(title) + 4, max((len(line) for line in message_lines), default=40) + 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)
scroll_offset = 0
max_scroll = max(0, len(message_lines) - (dialog_h - 6))
# Message
for i, line in enumerate(message):
dialog.addstr(3 + i, 2, line)
while True:
dialog.clear()
dialog.box()
# 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))
# Title
dialog.attron(curses.A_BOLD)
title_x = max(2, (dialog_w - len(title)) // 2)
try:
dialog.addstr(1, title_x, title[:dialog_w - 4])
except curses.error:
pass
dialog.attroff(curses.A_BOLD)
dialog.refresh()
dialog.getch()
# Display visible lines
visible_lines = message_lines[scroll_offset:scroll_offset + dialog_h - 6]
for i, line in enumerate(visible_lines):
try:
truncated_line = line[:dialog_w - 4]
dialog.addstr(3 + i, 2, truncated_line)
except curses.error:
pass
# Footer
if max_scroll > 0:
footer = f"↑/↓ Scroll Any other key to close"
else:
footer = "Press any key to close"
footer_x = max(2, (dialog_w - len(footer)) // 2)
try:
dialog.addstr(dialog_h - 2, footer_x, footer[:dialog_w - 4], curses.color_pair(3))
except curses.error:
pass
dialog.refresh()
# Handle input
key = dialog.getch()
if key == curses.KEY_UP and scroll_offset > 0:
scroll_offset -= 1
elif key == curses.KEY_DOWN and scroll_offset < max_scroll:
scroll_offset += 1
elif key == curses.KEY_PPAGE:
scroll_offset = max(0, scroll_offset - (dialog_h - 6))
elif key == curses.KEY_NPAGE:
scroll_offset = min(max_scroll, scroll_offset + (dialog_h - 6))
else:
break
def _save_nav_position(self):
"""Save current navigation position before changing views"""