Add GPG signing for entire markdown exports

When exporting to markdown (--export), the entire export document is now
signed with GPG if signing is enabled in settings.

Features:
- Builds export content in memory before signing
- Signs the complete document as one GPG clearsigned block
- Individual note signatures are preserved within the export
- Provides two layers of verification:
  1. Document-level: Verifies entire export hasn't been modified
  2. Note-level: Verifies individual notes haven't been tampered with

Verification workflow:
- Entire export: gpg --verify export.md
- Individual notes: Extract signature blocks and verify separately

Changes:
- Renamed write_note() to format_note_for_export() returning string
- Export content built in memory before file write
- Signs complete export if pgp_enabled=True
- Shows verification instructions after successful export

Example output:
  ✓ Export signed with GPG
  ✓ Exported to case-2024-001.md

  To verify the export:
    gpg --verify case-2024-001.md
This commit is contained in:
Claude
2025-12-13 19:42:12 +00:00
parent 96309319b9
commit 9248799e79

View File

@@ -79,68 +79,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()
# 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:
f.write("# Forensic Notes Export\n\n")
f.write(f"Generated on: {time.ctime()}\n\n")
f.write(final_content)
for case in storage.cases:
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")
print(f"✓ Exported to {output_file}")
f.write("### Case Notes\n")
if not case.notes:
f.write("_No notes._\n")
for note in case.notes:
write_note(f, note)
# Show verification instructions
if settings.get("pgp_enabled", False) and signed_export:
print(f"\nTo verify the export:")
print(f" gpg --verify {output_file}")
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:
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" - Hash: `{note.content_hash}`\n")
if note.signature:
f.write(" - **Signature Verified:**\n")
f.write(" ```\n")
lines.append(" - **Individual Note Signature:**\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")