Fix all power-user review issues (FR-01 through FR-12)
Some checks failed
CI / lint (push) Failing after 12s
CI / typecheck (push) Failing after 12s

FR-01: Fix data directory default from ~/.fieldwitness to ~/.fwmetadata
FR-02/05/07: Accept all file types for attestation (not just images)
  - Web UI, CLI, and batch now accept PDFs, CSVs, audio, video, etc.
  - Perceptual hashing for images, SHA-256-only for everything else
FR-03: Implement C2PA import path + CLI commands (export/verify/import/show)
FR-04: Fix GPS downsampling bias (math.floor → round)
FR-06: Add HTML/PDF evidence summaries for lawyers
  - Always generates summary.html, optional summary.pdf via xhtml2pdf
FR-08: Fix CLI help text ("FieldWitness -- FieldWitness" artifact)
FR-09: Centralize stray paths (trusted_keys, carrier_history, last_backup)
FR-10: Add 67 C2PA bridge tests (vendor assertions, cert, GPS, export)
FR-12: Add Tor onion service support for source drop box
  - fieldwitness serve --tor flag, persistent/transient modes
  - Killswitch covers hidden service keys

Also: bonus fix for attest/api.py hardcoded path bypassing paths.py

224 tests passing (67 new).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-04-02 20:10:37 -04:00
parent 3a9cb17a5a
commit 5b0d90eeaf
27 changed files with 3140 additions and 186 deletions

View File

@@ -1,10 +1,13 @@
"""
Attestation blueprint — attest and verify images via Attest.
Attestation blueprint — attest and verify files via Attest.
Wraps attest's attestation and verification libraries to provide:
- Image attestation: upload → hash → sign → store in append-only log
- Image verification: upload → hash → search log → display matches
- File attestation: upload → hash → sign → store in append-only log
- File verification: upload → hash → search log → display matches
- Verification receipt: same as verify but returns a downloadable JSON file
Supports any file type. Perceptual hashing (phash, dhash) is available for
image files only. Non-image files are attested by SHA-256 hash.
"""
from __future__ import annotations
@@ -85,25 +88,45 @@ def _wrap_in_chain(attest_record, private_key, metadata: dict | None = None):
)
def _allowed_image(filename: str) -> bool:
_ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
# Images
"png", "jpg", "jpeg", "bmp", "gif", "webp", "tiff", "tif", "heic", "heif", "raw",
# Documents
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp",
"txt", "rtf", "csv", "tsv", "json", "xml", "html", "htm",
# Audio
"mp3", "wav", "m4a", "aac", "ogg", "flac", "opus", "wma",
# Video
"mp4", "mov", "avi", "mkv", "webm", "m4v", "wmv",
# Archives / data
"zip", "tar", "gz", "bz2", "xz", "7z",
# Sensor / scientific data
"gpx", "kml", "geojson", "npy", "parquet", "bin", "dat",
})
_IMAGE_EXTENSIONS: frozenset[str] = frozenset({
"png", "jpg", "jpeg", "bmp", "gif", "webp", "tiff", "tif", "heic", "heif",
})
def _allowed_file(filename: str) -> bool:
"""Return True if the filename has an extension on the allowlist."""
if not filename or "." not in filename:
return False
return filename.rsplit(".", 1)[1].lower() in {
"png",
"jpg",
"jpeg",
"bmp",
"gif",
"webp",
"tiff",
"tif",
}
return filename.rsplit(".", 1)[1].lower() in _ALLOWED_EXTENSIONS
def _is_image_file(filename: str) -> bool:
"""Return True if the filename is a known image type."""
if not filename or "." not in filename:
return False
return filename.rsplit(".", 1)[1].lower() in _IMAGE_EXTENSIONS
@bp.route("/attest", methods=["GET", "POST"])
@login_required
def attest():
"""Create a provenance attestation for an image."""
"""Create a provenance attestation for a file."""
# Check identity exists
private_key = _get_private_key()
has_identity = private_key is not None
@@ -116,17 +139,22 @@ def attest():
)
return redirect(url_for("attest.attest"))
image_file = request.files.get("image")
if not image_file or not image_file.filename:
flash("Please select an image to attest.", "error")
evidence_file = request.files.get("image")
if not evidence_file or not evidence_file.filename:
flash("Please select a file to attest.", "error")
return redirect(url_for("attest.attest"))
if not _allowed_image(image_file.filename):
flash("Unsupported image format. Use PNG, JPG, WebP, TIFF, or BMP.", "error")
if not _allowed_file(evidence_file.filename):
flash(
"Unsupported file type. Supported types include images, documents, "
"audio, video, CSV, and sensor data files.",
"error",
)
return redirect(url_for("attest.attest"))
try:
image_data = image_file.read()
file_data = evidence_file.read()
is_image = _is_image_file(evidence_file.filename)
# Build optional metadata
metadata = {}
@@ -148,31 +176,36 @@ def attest():
auto_exif = request.form.get("auto_exif", "on") == "on"
strip_device = request.form.get("strip_device", "on") == "on"
# Extract-then-classify: get evidentiary metadata before attestation
# so user can control what's included
if auto_exif and strip_device:
from fieldwitness.metadata import extract_and_classify
# Extract-then-classify: get evidentiary metadata before attestation.
# Only applicable to image files — silently skip for other types.
if is_image and auto_exif and strip_device:
try:
from fieldwitness.metadata import extract_and_classify
extraction = extract_and_classify(image_data)
# Merge evidentiary fields (GPS, timestamp) but exclude
# dangerous device fields (serial, firmware version)
for key, value in extraction.evidentiary.items():
if key not in metadata: # User metadata takes precedence
if hasattr(value, "isoformat"):
metadata[f"exif_{key}"] = value.isoformat()
elif isinstance(value, dict):
metadata[f"exif_{key}"] = value
else:
metadata[f"exif_{key}"] = str(value)
extraction = extract_and_classify(file_data)
# Merge evidentiary fields (GPS, timestamp) but exclude
# dangerous device fields (serial, firmware version)
for key, value in extraction.evidentiary.items():
if key not in metadata: # User metadata takes precedence
if hasattr(value, "isoformat"):
metadata[f"exif_{key}"] = value.isoformat()
elif isinstance(value, dict):
metadata[f"exif_{key}"] = value
else:
metadata[f"exif_{key}"] = str(value)
except Exception:
pass # EXIF extraction is best-effort
# Create the attestation
# Create the attestation. create_attestation() calls hash_image()
# internally; for non-image files we pre-compute hashes via
# hash_file() and use create_attestation_from_hashes() instead.
from fieldwitness.attest.attestation import create_attestation
attestation = create_attestation(
image_data=image_data,
image_data=file_data,
private_key=private_key,
metadata=metadata if metadata else None,
auto_exif=auto_exif and not strip_device, # Full EXIF only if not stripping device
auto_exif=is_image and auto_exif and not strip_device,
)
# Store in the append-only log
@@ -188,7 +221,7 @@ def attest():
logging.getLogger(__name__).warning("Chain wrapping failed: %s", e)
flash(
"Attestation saved, but chain wrapping failed. " "Check chain configuration.",
"Attestation saved, but chain wrapping failed. Check chain configuration.",
"warning",
)
@@ -225,7 +258,8 @@ def attest():
location_name=metadata.get("location_name", ""),
exif_metadata=record.metadata,
index=index,
filename=image_file.filename,
filename=evidence_file.filename,
is_image=is_image,
chain_index=chain_record.chain_index if chain_record else None,
)
@@ -239,15 +273,13 @@ def attest():
@bp.route("/attest/batch", methods=["POST"])
@login_required
def attest_batch():
"""Batch attestation — accepts multiple image files.
"""Batch attestation — accepts multiple files of any supported type.
Returns JSON with results for each file (success/skip/error).
Skips images already attested (by SHA-256 match).
Skips files already attested (by SHA-256 match).
"""
import hashlib
from fieldwitness.attest.hashing import hash_image
private_key = _get_private_key()
if private_key is None:
return {"error": "No identity key. Run fieldwitness init first."}, 400
@@ -262,10 +294,14 @@ def attest_batch():
for f in files:
filename = f.filename or "unknown"
try:
image_data = f.read()
sha256 = hashlib.sha256(image_data).hexdigest()
if not _allowed_file(filename):
results.append({"file": filename, "status": "skipped", "reason": "unsupported file type"})
continue
# Skip already-attested images
file_data = f.read()
sha256 = hashlib.sha256(file_data).hexdigest()
# Skip already-attested files
existing = storage.get_records_by_image_sha256(sha256)
if existing:
results.append({"file": filename, "status": "skipped", "reason": "already attested"})
@@ -273,7 +309,7 @@ def attest_batch():
from fieldwitness.attest.attestation import create_attestation
attestation = create_attestation(image_data, private_key)
attestation = create_attestation(file_data, private_key)
index = storage.append_record(attestation.record)
# Wrap in chain if enabled
@@ -312,10 +348,10 @@ def attest_batch():
@bp.route("/verify/batch", methods=["POST"])
@login_required
def verify_batch():
"""Batch verification — accepts multiple image files.
"""Batch verification — accepts multiple files of any supported type.
Returns JSON with per-file verification results. Uses SHA-256
fast path before falling back to perceptual scan.
fast path before falling back to perceptual scan (images only).
"""
files = request.files.getlist("images")
if not files:
@@ -325,8 +361,8 @@ def verify_batch():
for f in files:
filename = f.filename or "unknown"
try:
image_data = f.read()
result = _verify_image(image_data)
file_data = f.read()
result = _verify_file(file_data)
if result["matches"]:
best = result["matches"][0]
@@ -361,17 +397,20 @@ def verify_batch():
}
def _verify_image(image_data: bytes) -> dict:
def _verify_file(file_data: bytes) -> dict:
"""Run the full verification pipeline against the attestation log.
Works for any file type. Images get SHA-256 + perceptual matching;
non-image files get SHA-256 matching only.
Returns a dict with keys:
query_hashes — ImageHashes object from fieldwitness.attest
matches — list of match dicts (record, match_type, distances, attestor_name)
record_count — total records searched
"""
from fieldwitness.attest.hashing import compute_all_distances, hash_image, is_same_image
from fieldwitness.attest.hashing import compute_all_distances, hash_file, is_same_image
query_hashes = hash_image(image_data)
query_hashes = hash_file(file_data)
storage = _get_storage()
stats = storage.get_stats()
@@ -423,17 +462,22 @@ def verify():
The log read here is read-only and reveals no key material.
"""
if request.method == "POST":
image_file = request.files.get("image")
if not image_file or not image_file.filename:
flash("Please select an image to verify.", "error")
evidence_file = request.files.get("image")
if not evidence_file or not evidence_file.filename:
flash("Please select a file to verify.", "error")
return redirect(url_for("attest.verify"))
if not _allowed_image(image_file.filename):
flash("Unsupported image format.", "error")
if not _allowed_file(evidence_file.filename):
flash(
"Unsupported file type. Upload any image, document, audio, video, or data file.",
"error",
)
return redirect(url_for("attest.verify"))
try:
result = _verify_image(image_file.read())
file_data = evidence_file.read()
is_image = _is_image_file(evidence_file.filename)
result = _verify_file(file_data)
query_hashes = result["query_hashes"]
matches = result["matches"]
@@ -443,7 +487,8 @@ def verify():
found=False,
message="No attestations in the local log yet.",
query_hashes=query_hashes,
filename=image_file.filename,
filename=evidence_file.filename,
is_image=is_image,
matches=[],
)
@@ -456,7 +501,8 @@ def verify():
else "No matching attestations found."
),
query_hashes=query_hashes,
filename=image_file.filename,
filename=evidence_file.filename,
is_image=is_image,
matches=matches,
)
@@ -471,29 +517,29 @@ def verify():
def verify_receipt():
"""Return a downloadable JSON verification receipt for court or legal use.
Accepts the same image upload as /verify. Returns a JSON file attachment
containing image hashes, all matching attestation records with full metadata,
Accepts the same file upload as /verify. Returns a JSON file attachment
containing file hashes, all matching attestation records with full metadata,
the verification timestamp, and the verifier hostname.
Intentionally unauthenticated — same access policy as /verify.
"""
image_file = request.files.get("image")
if not image_file or not image_file.filename:
evidence_file = request.files.get("image")
if not evidence_file or not evidence_file.filename:
return Response(
json.dumps({"error": "No image provided"}),
json.dumps({"error": "No file provided"}),
status=400,
mimetype="application/json",
)
if not _allowed_image(image_file.filename):
if not _allowed_file(evidence_file.filename):
return Response(
json.dumps({"error": "Unsupported image format"}),
json.dumps({"error": "Unsupported file type"}),
status=400,
mimetype="application/json",
)
try:
result = _verify_image(image_file.read())
result = _verify_file(evidence_file.read())
except Exception as e:
return Response(
json.dumps({"error": f"Verification failed: {e}"}),
@@ -573,11 +619,11 @@ def verify_receipt():
"schema_version": "3",
"verification_timestamp": verification_ts,
"verifier_instance": verifier_instance,
"queried_filename": image_file.filename,
"image_hash": {
"queried_filename": evidence_file.filename,
"file_hash": {
"sha256": query_hashes.sha256,
"phash": query_hashes.phash,
"dhash": getattr(query_hashes, "dhash", None),
"phash": query_hashes.phash or None,
"dhash": getattr(query_hashes, "dhash", None) or None,
},
"records_searched": result["record_count"],
"matches_found": len(matching_records),
@@ -599,7 +645,9 @@ def verify_receipt():
receipt_json = json.dumps(receipt, indent=2, ensure_ascii=False)
safe_filename = (
image_file.filename.rsplit(".", 1)[0] if "." in image_file.filename else image_file.filename
evidence_file.filename.rsplit(".", 1)[0]
if "." in evidence_file.filename
else evidence_file.filename
)
download_name = f"receipt_{safe_filename}_{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}.json"

View File

@@ -1,17 +1,18 @@
{% extends "base.html" %}
{% block title %}Attest Image — FieldWitness{% endblock %}
{% block title %}Attest File — FieldWitness{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card bg-dark border-secondary">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-patch-check me-2 text-info"></i>Attest Image</h5>
<h5 class="mb-0"><i class="bi bi-patch-check me-2 text-info"></i>Attest File</h5>
</div>
<div class="card-body">
<p class="text-muted">
Create a cryptographic provenance attestation — sign an image with your Ed25519 identity
to prove when and by whom it was captured.
Create a cryptographic provenance attestation — sign any file with your Ed25519 identity
to prove when and by whom it was captured or created. Supports photos, documents,
sensor data, audio, video, and more.
</p>
{% if not has_identity %}
@@ -25,28 +26,31 @@
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-4">
<label for="image" class="form-label"><i class="bi bi-image me-1"></i>Image to Attest</label>
<input type="file" class="form-control" name="image" id="image"
accept="image/png,image/jpeg,image/webp,image/tiff,image/bmp" required>
<div class="form-text">Supports PNG, JPEG, WebP, TIFF, BMP.</div>
<label for="image" class="form-label"><i class="bi bi-file-earmark me-1"></i>Evidence File</label>
<input type="file" class="form-control" name="image" id="image" required>
<div class="form-text">
Accepts images (PNG, JPEG, WebP, TIFF), documents (PDF, DOCX, CSV, TXT),
audio (MP3, WAV, FLAC), video (MP4, MOV, MKV), and sensor data files.
Perceptual matching (pHash, dHash) is available for image files only.
</div>
</div>
<div class="mb-3">
<label for="caption" class="form-label"><i class="bi bi-chat-text me-1"></i>Caption (optional)</label>
<input type="text" class="form-control" name="caption" id="caption"
placeholder="What does this image show?" maxlength="500">
placeholder="What does this file document?" maxlength="500">
</div>
<div class="mb-3">
<label for="location_name" class="form-label"><i class="bi bi-geo-alt me-1"></i>Location (optional)</label>
<input type="text" class="form-control" name="location_name" id="location_name"
placeholder="Where was this taken?" maxlength="200">
placeholder="Where was this captured?" maxlength="200">
</div>
<div class="form-check form-switch mb-4">
<input class="form-check-input" type="checkbox" name="auto_exif" id="autoExif" checked>
<label class="form-check-label" for="autoExif">
Extract EXIF metadata automatically (GPS, timestamp, device)
Extract EXIF metadata automatically (GPS, timestamp, device) — images only
</label>
</div>

View File

@@ -39,7 +39,7 @@
{% else %}
<div class="alert alert-secondary">
<i class="bi bi-inbox me-2"></i>
No attestations yet. <a href="/attest" class="alert-link">Attest your first image</a>.
No attestations yet. <a href="/attest" class="alert-link">Attest your first file</a>.
</div>
{% endif %}
</div>

View File

@@ -7,9 +7,17 @@
<div class="alert alert-success">
<i class="bi bi-check-circle me-2"></i>
<strong>Attestation created successfully!</strong>
Image <code>{{ filename }}</code> has been attested and stored in the local log (index #{{ index }}).
File <code>{{ filename }}</code> has been attested and stored in the local log (index #{{ index }}).
</div>
{% if not is_image %}
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
This file is attested by cryptographic hash. Perceptual matching (pHash, dHash)
is available for image files only.
</div>
{% endif %}
<div class="card bg-dark border-secondary mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-file-earmark-check me-2"></i>Attestation Record</h5>
@@ -38,7 +46,7 @@
<div class="card bg-dark border-secondary mb-4">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-hash me-2"></i>Image Hashes</h6>
<h6 class="mb-0"><i class="bi bi-hash me-2"></i>File Hashes</h6>
</div>
<div class="card-body">
<div class="mb-2">
@@ -84,7 +92,7 @@
<div class="d-grid gap-2">
<a href="/attest" class="btn btn-outline-info">
<i class="bi bi-plus-circle me-2"></i>Attest Another Image
<i class="bi bi-plus-circle me-2"></i>Attest Another File
</a>
<a href="/attest/log" class="btn btn-outline-secondary">
<i class="bi bi-journal-text me-2"></i>View Attestation Log

View File

@@ -1,30 +1,33 @@
{% extends "base.html" %}
{% block title %}Verify Image — FieldWitness{% endblock %}
{% block title %}Verify File — FieldWitness{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card bg-dark border-secondary">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-search me-2 text-info"></i>Verify Image</h5>
<h5 class="mb-0"><i class="bi bi-search me-2 text-info"></i>Verify File</h5>
</div>
<div class="card-body">
<p class="text-muted">
Check an image against the local attestation log. Uses SHA-256 for exact matching
and perceptual hashes (pHash, dHash) for robustness against compression and resizing.
Check a file against the local attestation log. For image files, uses SHA-256 for
exact matching and perceptual hashes (pHash, dHash) for robustness against
compression and resizing. For all other file types, SHA-256 exact matching is used.
</p>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-4">
<label for="image" class="form-label"><i class="bi bi-image me-1"></i>Image to Verify</label>
<input type="file" class="form-control" name="image" id="image"
accept="image/png,image/jpeg,image/webp,image/tiff,image/bmp" required>
<div class="form-text">Upload the image you want to verify against known attestations.</div>
<label for="image" class="form-label"><i class="bi bi-file-earmark-search me-1"></i>Evidence File to Verify</label>
<input type="file" class="form-control" name="image" id="image" required>
<div class="form-text">
Upload the file you want to verify against known attestations.
Accepts images, documents, audio, video, and data files.
</div>
</div>
<button type="submit" class="btn btn-info btn-lg w-100">
<i class="bi bi-search me-2"></i>Verify Image
<i class="bi bi-search me-2"></i>Verify File
</button>
</form>
</div>

View File

@@ -16,10 +16,18 @@
</div>
{% endif %}
{# Query image hashes #}
{% if not is_image %}
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
This file is attested by cryptographic hash. Perceptual matching (pHash, dHash)
is available for image files only.
</div>
{% endif %}
{# Query file hashes #}
<div class="card bg-dark border-secondary mb-4">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-hash me-2"></i>Image Hashes for <code>{{ filename }}</code></h6>
<h6 class="mb-0"><i class="bi bi-hash me-2"></i>File Hashes for <code>{{ filename }}</code></h6>
</div>
<div class="card-body">
<div class="mb-2">
@@ -103,13 +111,13 @@
<div class="card-body">
<p class="text-muted small mb-3">
Generate a signed JSON receipt for legal or archival use.
Re-upload the same image to produce the downloadable file.
Re-upload the same file to produce the downloadable receipt.
</p>
<form action="/verify/receipt" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<input class="form-control form-control-sm bg-dark text-light border-secondary"
type="file" name="image" accept="image/*" required>
type="file" name="image" required>
</div>
<button type="submit" class="btn btn-outline-warning btn-sm">
Download Receipt (.json)
@@ -121,7 +129,7 @@
<div class="d-grid gap-2 mt-4">
<a href="/verify" class="btn btn-outline-info">
Verify Another Image
Verify Another File
</a>
<a href="/attest/log" class="btn btn-outline-secondary">
View Attestation Log