Implement 6 evidence lifecycle features
1. Client-side SHA-256 in drop box: browser computes and displays file fingerprints via SubtleCrypto before upload. Receipt codes are HMAC-derived from file hash so source can verify correspondence. Source sees hash before submitting. 2. Drop box token persistence: replaced in-memory dict with SQLite (dropbox.db). Tokens and receipts survive server restarts. Receipt verification now returns filename, SHA-256, and timestamp. 3. RFC 3161 trusted timestamps + manual anchors: new federation/anchors.py with get_chain_head_anchor(), submit_rfc3161(), save_anchor(), and manual export format. CLI: `soosef chain anchor [--tsa URL]`. A single anchor implicitly timestamps every preceding chain record. 4. Derived work lineage: attestation metadata supports derived_from (parent record ID) and derivation_type (crop, redact, brightness, etc.) for tracking edits through the chain of custody. 5. Self-contained evidence package: new soosef.evidence module with export_evidence_package() producing a ZIP with images, attestation records, chain data, public key, standalone verify.py script, and README. 6. Cold archive export: new soosef.archive module with export_cold_archive() bundling chain.bin, verisoo log, LMDB index, keys, anchors, trusted keys, ALGORITHMS.txt documenting all crypto, and verification instructions. Designed for OAIS (ISO 14721) alignment. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -133,12 +133,17 @@ def attest():
|
||||
caption = request.form.get("caption", "").strip()
|
||||
location_name = request.form.get("location_name", "").strip()
|
||||
investigation = request.form.get("investigation", "").strip()
|
||||
parent_record_id = request.form.get("parent_record_id", "").strip()
|
||||
derivation_type = request.form.get("derivation_type", "").strip()
|
||||
if caption:
|
||||
metadata["caption"] = caption
|
||||
if location_name:
|
||||
metadata["location_name"] = location_name
|
||||
if investigation:
|
||||
metadata["investigation"] = investigation
|
||||
if parent_record_id:
|
||||
metadata["derived_from"] = parent_record_id
|
||||
metadata["derivation_type"] = derivation_type or "unspecified"
|
||||
|
||||
auto_exif = request.form.get("auto_exif", "on") == "on"
|
||||
strip_device = request.form.get("strip_device", "on") == "on"
|
||||
|
||||
@@ -22,14 +22,12 @@ 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
|
||||
from soosef.paths import AUTH_DIR, 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"
|
||||
_DB_PATH = AUTH_DIR / "dropbox.db"
|
||||
|
||||
|
||||
def _ensure_token_dir():
|
||||
@@ -37,6 +35,75 @@ def _ensure_token_dir():
|
||||
_TOKEN_DIR.chmod(0o700)
|
||||
|
||||
|
||||
def _get_db():
|
||||
"""Get SQLite connection for drop box tokens."""
|
||||
import sqlite3
|
||||
|
||||
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(_DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
max_files INTEGER NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
)""")
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS receipts (
|
||||
receipt_code TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
filename TEXT,
|
||||
sha256 TEXT,
|
||||
received_at TEXT,
|
||||
FOREIGN KEY (token) REFERENCES tokens(token)
|
||||
)""")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def _get_token(token: str) -> dict | None:
|
||||
"""Load a token from SQLite. Returns dict or None if expired/missing."""
|
||||
conn = _get_db()
|
||||
row = conn.execute("SELECT * FROM tokens WHERE token = ?", (token,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return None
|
||||
if datetime.fromisoformat(row["expires_at"]) < datetime.now(UTC):
|
||||
conn.execute("DELETE FROM tokens WHERE token = ?", (token,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return None
|
||||
data = dict(row)
|
||||
# Load receipts
|
||||
receipts = conn.execute(
|
||||
"SELECT receipt_code FROM receipts WHERE token = ?", (token,)
|
||||
).fetchall()
|
||||
data["receipts"] = [r["receipt_code"] for r in receipts]
|
||||
conn.close()
|
||||
return data
|
||||
|
||||
|
||||
def _get_all_tokens() -> dict[str, dict]:
|
||||
"""Load all non-expired tokens."""
|
||||
conn = _get_db()
|
||||
now = datetime.now(UTC).isoformat()
|
||||
# Clean expired
|
||||
conn.execute("DELETE FROM tokens WHERE expires_at < ?", (now,))
|
||||
conn.commit()
|
||||
rows = conn.execute("SELECT * FROM tokens").fetchall()
|
||||
result = {}
|
||||
for row in rows:
|
||||
data = dict(row)
|
||||
receipts = conn.execute(
|
||||
"SELECT receipt_code FROM receipts WHERE token = ?", (row["token"],)
|
||||
).fetchall()
|
||||
data["receipts"] = [r["receipt_code"] for r in receipts]
|
||||
result[row["token"]] = data
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
@bp.route("/admin", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def admin():
|
||||
@@ -49,14 +116,13 @@ def admin():
|
||||
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": [],
|
||||
}
|
||||
conn = _get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tokens (token, label, created_at, expires_at, max_files, used) VALUES (?, ?, ?, ?, ?, 0)",
|
||||
(token, label, datetime.now(UTC).isoformat(), (datetime.now(UTC) + timedelta(hours=hours)).isoformat(), max_files),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
log_action(
|
||||
actor=request.environ.get("REMOTE_USER", "admin"),
|
||||
@@ -70,27 +136,21 @@ def admin():
|
||||
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")
|
||||
tok = request.form.get("token", "")
|
||||
conn = _get_db()
|
||||
conn.execute("DELETE FROM receipts WHERE token = ?", (tok,))
|
||||
conn.execute("DELETE FROM tokens WHERE token = ?", (tok,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
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)
|
||||
return render_template("dropbox/admin.html", tokens=_get_all_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]
|
||||
data = _get_token(token)
|
||||
if data is None:
|
||||
return None
|
||||
if data["used"] >= data["max_files"]:
|
||||
return None
|
||||
@@ -175,8 +235,14 @@ def upload(token):
|
||||
except Exception:
|
||||
pass # Attestation is best-effort; don't fail the upload
|
||||
|
||||
# Generate receipt code
|
||||
receipt_code = secrets.token_hex(8)
|
||||
# Receipt code derived from file hash via HMAC — the source can
|
||||
# independently verify their receipt corresponds to specific content
|
||||
import hmac
|
||||
|
||||
receipt_code = hmac.new(
|
||||
token.encode(), sha256.encode(), hashlib.sha256
|
||||
).hexdigest()[:16]
|
||||
|
||||
receipts.append({
|
||||
"filename": f.filename,
|
||||
"sha256": sha256,
|
||||
@@ -184,8 +250,16 @@ def upload(token):
|
||||
"received_at": datetime.now(UTC).isoformat(),
|
||||
})
|
||||
|
||||
# Persist receipt and increment used count in SQLite
|
||||
conn = _get_db()
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO receipts (receipt_code, token, filename, sha256, received_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(receipt_code, token, f.filename, sha256, datetime.now(UTC).isoformat()),
|
||||
)
|
||||
conn.execute("UPDATE tokens SET used = used + 1 WHERE token = ?", (token,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
token_data["used"] += 1
|
||||
token_data["receipts"].append(receipt_code)
|
||||
|
||||
remaining = token_data["max_files"] - token_data["used"]
|
||||
|
||||
@@ -200,21 +274,56 @@ def upload(token):
|
||||
|
||||
return Response(receipt_text, content_type="text/plain")
|
||||
|
||||
# GET — show upload form (minimal, no SooSeF branding for source safety)
|
||||
# GET — show upload form with client-side SHA-256 hashing
|
||||
# Minimal page, no SooSeF branding (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>
|
||||
<style>
|
||||
body{{font-family:sans-serif;max-width:600px;margin:40px auto;padding:20px;color:#333}}
|
||||
input[type=file]{{margin:10px 0}}
|
||||
button{{padding:10px 20px;font-size:16px}}
|
||||
#hashes{{background:#f5f5f5;padding:10px;border-radius:4px;font-family:monospace;
|
||||
font-size:12px;margin:10px 0;display:none;white-space:pre-wrap}}
|
||||
.hash-label{{color:#666;font-size:11px}}
|
||||
</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>
|
||||
<p>Your files will be fingerprinted in your browser before upload. Save the
|
||||
fingerprints — they prove exactly what you submitted.</p>
|
||||
<form method="POST" enctype="multipart/form-data" id="uploadForm">
|
||||
<input type="file" name="files" id="fileInput" multiple
|
||||
accept="image/*,.pdf,.doc,.docx,.txt"><br>
|
||||
<div id="hashes"></div>
|
||||
<button type="submit" id="submitBtn" disabled>Computing fingerprints...</button>
|
||||
</form>
|
||||
<p style="color:#666;font-size:12px">This link will expire automatically. Do not bookmark it.</p>
|
||||
<script>
|
||||
// Client-side SHA-256 via SubtleCrypto — runs in browser, no server round-trip
|
||||
async function hashFile(file) {{
|
||||
const buffer = await file.arrayBuffer();
|
||||
const hash = await crypto.subtle.digest('SHA-256', buffer);
|
||||
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2,'0')).join('');
|
||||
}}
|
||||
document.getElementById('fileInput').addEventListener('change', async function() {{
|
||||
const files = this.files;
|
||||
const hashDiv = document.getElementById('hashes');
|
||||
const btn = document.getElementById('submitBtn');
|
||||
if (!files.length) {{ hashDiv.style.display='none'; btn.disabled=true; return; }}
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Computing fingerprints...';
|
||||
hashDiv.style.display = 'block';
|
||||
hashDiv.innerHTML = '';
|
||||
for (const file of files) {{
|
||||
const hash = await hashFile(file);
|
||||
hashDiv.innerHTML += '<span class="hash-label">' + file.name + ':</span>\\n' + hash + '\\n\\n';
|
||||
}}
|
||||
hashDiv.innerHTML += '<span class="hash-label">Save these fingerprints before uploading.</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Upload';
|
||||
}});
|
||||
</script>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
@@ -225,12 +334,20 @@ def verify_receipt():
|
||||
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",
|
||||
)
|
||||
conn = _get_db()
|
||||
row = conn.execute(
|
||||
"SELECT filename, sha256, received_at FROM receipts WHERE receipt_code = ?", (code,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
return Response(
|
||||
f"Receipt {code} is VALID.\n"
|
||||
f"File: {row['filename']}\n"
|
||||
f"SHA-256: {row['sha256']}\n"
|
||||
f"Received: {row['received_at']}\n",
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
return Response(
|
||||
f"Receipt {code} was not found. It may have expired.",
|
||||
|
||||
Reference in New Issue
Block a user