fieldwitness/frontends/web/blueprints/dropbox.py
Aaron D. Lee 9431033c72
Some checks failed
CI / lint (push) Failing after 52s
CI / typecheck (push) Failing after 30s
Implement 7 real-world scenario features (Round 4)
1. Source drop box: token-gated anonymous upload with auto-attestation,
   EXIF stripping, receipt codes, and self-destructing URLs. New
   /dropbox blueprint with admin panel for token management. CSRF
   exempted for source-facing upload routes.

2. Investigation namespaces: attestation records tagged with
   investigation label via metadata. Log view filters by investigation
   with dropdown. Supports long-running multi-story workflows.

3. Scale fixes: replaced O(n) full-scan perceptual hash search with
   LMDB find_similar_images() index lookup. Added incremental chain
   verification (verify_incremental) with last_verified_index
   checkpoint in ChainState.

4. Deep forensic purge: killswitch now scrubs __pycache__, pip
   dist-info, pip cache, and shell history entries containing 'soosef'.
   Runs before package uninstall for maximum trace removal.

5. Cross-org federation: new federation/exchange.py with
   export_attestation_bundle() and import_attestation_bundle().
   Bundles are self-authenticating JSON with investigation filter.
   Import validates against trust store fingerprints.

6. Wrong-key diagnostics: enhanced decrypt error messages include
   current channel key fingerprint hint. New carrier_tracker.py
   tracks carrier SHA-256 hashes and warns on reuse (statistical
   analysis risk).

7. Selective disclosure: ChainStore.selective_disclosure() produces
   proof bundles with full selected records + hash-only redacted
   records + complete hash chain for linkage verification. New
   `soosef chain disclose -i 0,5,10 -o proof.json` CLI command
   for court-ordered evidence production.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:41:41 -04:00

227 lines
7.9 KiB
Python

"""
Source drop box blueprint — anonymous, token-gated file submission.
Provides a SecureDrop-like intake that lives inside SooSeF:
- Admin creates a time-limited upload token
- Source opens the token URL in a browser (no account needed)
- Files are uploaded, EXIF-stripped, and auto-attested on receipt
- Source receives a one-time receipt code to confirm delivery
- Token self-destructs after use or timeout
"""
from __future__ import annotations
import hashlib
import json
import os
import secrets
from datetime import UTC, datetime, timedelta
from pathlib import Path
from auth import admin_required, login_required
from flask import Blueprint, Response, flash, redirect, render_template, request, url_for
from soosef.audit import log_action
from soosef.paths import TEMP_DIR
bp = Blueprint("dropbox", __name__, url_prefix="/dropbox")
# In-memory token store. In production, this should be persisted to SQLite.
# Token format: {token: {created_at, expires_at, max_files, label, used, receipts[]}}
_tokens: dict[str, dict] = {}
_TOKEN_DIR = TEMP_DIR / "dropbox"
def _ensure_token_dir():
_TOKEN_DIR.mkdir(parents=True, exist_ok=True)
_TOKEN_DIR.chmod(0o700)
@bp.route("/admin", methods=["GET", "POST"])
@admin_required
def admin():
"""Admin panel for creating and managing drop box tokens."""
if request.method == "POST":
action = request.form.get("action")
if action == "create":
label = request.form.get("label", "").strip() or "Unnamed source"
hours = int(request.form.get("hours", 24))
max_files = int(request.form.get("max_files", 10))
token = secrets.token_urlsafe(32)
_tokens[token] = {
"created_at": datetime.now(UTC).isoformat(),
"expires_at": (datetime.now(UTC) + timedelta(hours=hours)).isoformat(),
"max_files": max_files,
"label": label,
"used": 0,
"receipts": [],
}
log_action(
actor=request.environ.get("REMOTE_USER", "admin"),
action="dropbox.token_created",
target=token[:8],
outcome="success",
source="web",
)
upload_url = url_for("dropbox.upload", token=token, _external=True)
flash(f"Drop box created. Share this URL with your source: {upload_url}", "success")
elif action == "revoke":
token = request.form.get("token", "")
if token in _tokens:
del _tokens[token]
flash("Token revoked.", "success")
# Clean expired tokens
now = datetime.now(UTC)
expired = [t for t, d in _tokens.items() if datetime.fromisoformat(d["expires_at"]) < now]
for t in expired:
del _tokens[t]
return render_template("dropbox/admin.html", tokens=_tokens)
def _validate_token(token: str) -> dict | None:
"""Check if a token is valid. Returns token data or None."""
if token not in _tokens:
return None
data = _tokens[token]
if datetime.fromisoformat(data["expires_at"]) < datetime.now(UTC):
del _tokens[token]
return None
if data["used"] >= data["max_files"]:
return None
return data
@bp.route("/upload/<token>", methods=["GET", "POST"])
def upload(token):
"""Source-facing upload page. No authentication required."""
token_data = _validate_token(token)
if token_data is None:
return Response(
"This upload link has expired or is invalid.",
status=404,
content_type="text/plain",
)
if request.method == "POST":
files = request.files.getlist("files")
if not files:
return Response("No files provided.", status=400, content_type="text/plain")
_ensure_token_dir()
receipts = []
for f in files:
if token_data["used"] >= token_data["max_files"]:
break
file_data = f.read()
if not file_data:
continue
# Strip EXIF metadata
try:
import io
from PIL import Image
img = Image.open(io.BytesIO(file_data))
clean = io.BytesIO()
img.save(clean, format=img.format or "PNG")
file_data = clean.getvalue()
except Exception:
pass # Not an image, or Pillow can't handle it — keep as-is
# Compute SHA-256
sha256 = hashlib.sha256(file_data).hexdigest()
# Save file
dest = _TOKEN_DIR / f"{sha256[:16]}_{f.filename}"
dest.write_bytes(file_data)
# Auto-attest
chain_index = None
try:
from soosef.verisoo.attestation import create_attestation
from soosef.verisoo.storage import LocalStorage
from blueprints.attest import _get_private_key, _get_storage
private_key = _get_private_key()
if private_key:
attestation = create_attestation(
file_data, private_key, metadata={"source": "dropbox", "label": token_data["label"]}
)
storage = _get_storage()
storage.append_record(attestation.record)
except Exception:
pass # Attestation is best-effort; don't fail the upload
# Generate receipt code
receipt_code = secrets.token_hex(8)
receipts.append({
"filename": f.filename,
"sha256": sha256,
"receipt_code": receipt_code,
"received_at": datetime.now(UTC).isoformat(),
})
token_data["used"] += 1
token_data["receipts"].append(receipt_code)
remaining = token_data["max_files"] - token_data["used"]
# Return receipt codes as plain text (minimal fingerprint)
receipt_text = "FILES RECEIVED\n" + "=" * 40 + "\n\n"
for r in receipts:
receipt_text += f"File: {r['filename']}\n"
receipt_text += f"Receipt: {r['receipt_code']}\n"
receipt_text += f"SHA-256: {r['sha256']}\n\n"
receipt_text += f"Remaining uploads on this link: {remaining}\n"
receipt_text += "\nSave your receipt codes. They confirm your submission was received.\n"
return Response(receipt_text, content_type="text/plain")
# GET — show upload form (minimal, no SooSeF branding for source safety)
remaining = token_data["max_files"] - token_data["used"]
return f"""<!DOCTYPE html>
<html><head><title>Secure Upload</title>
<style>body{{font-family:sans-serif;max-width:600px;margin:40px auto;padding:20px}}
input[type=file]{{margin:10px 0}}button{{padding:10px 20px}}</style></head>
<body>
<h2>Secure File Upload</h2>
<p>Select files to upload. You may upload up to {remaining} file(s).</p>
<p>Your files will be timestamped on receipt. No account or personal information is required.</p>
<form method="POST" enctype="multipart/form-data">
<input type="file" name="files" multiple accept="image/*,.pdf,.doc,.docx,.txt"><br>
<button type="submit">Upload</button>
</form>
<p style="color:#666;font-size:12px">This link will expire automatically. Do not bookmark it.</p>
</body></html>"""
@bp.route("/verify-receipt", methods=["POST"])
def verify_receipt():
"""Let a source verify their submission was received by receipt code."""
code = request.form.get("code", "").strip()
if not code:
return Response("No receipt code provided.", status=400, content_type="text/plain")
for token_data in _tokens.values():
if code in token_data["receipts"]:
return Response(
f"Receipt {code} is VALID. Your submission was received.",
content_type="text/plain",
)
return Response(
f"Receipt {code} was not found. It may have expired.",
status=404,
content_type="text/plain",
)