Add Verisoo attest/verify web MVP — full attestation lifecycle

Attest page (/attest):
- Image upload with optional caption and location
- EXIF auto-extraction toggle
- Creates Ed25519-signed attestation record
- Stores in verisoo append-only binary log + LMDB index
- Displays: record ID, attestor fingerprint, timestamp, image hashes

Verify page (/verify):
- Image upload for verification against local attestation log
- SHA-256 exact matching + perceptual hash matching (pHash, dHash)
- Shows match type (exact/perceptual), hash distances, attestor info
- Color-coded distance badges (green=0, info<5, warning<10, danger>=10)

Attestation log (/attest/log):
- Lists recent attestations with short ID, attestor, timestamp, SHA-256
- Shows total record count

Verified: full lifecycle works — attest image → verify same image → exact match found

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee 2026-03-31 17:02:49 -04:00
parent 317ef0f2ae
commit 067c4073ee
6 changed files with 548 additions and 31 deletions

View File

@ -1,34 +1,241 @@
"""
Attestation blueprint attest and verify images via Verisoo.
Wraps verisoo's attestation.create_attestation() and
verification.verify_image() with a web UI.
Wraps verisoo'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
"""
from flask import Blueprint, render_template
from __future__ import annotations
import io
from datetime import UTC, datetime
from pathlib import Path
from flask import Blueprint, flash, redirect, render_template, request, url_for
bp = Blueprint("attest", __name__)
def _get_storage():
"""Get verisoo LocalStorage pointed at soosef's attestation directory."""
from verisoo.storage import LocalStorage
from soosef.paths import ATTESTATIONS_DIR
return LocalStorage(base_path=ATTESTATIONS_DIR)
def _get_private_key():
"""Load the Ed25519 private key from soosef identity directory."""
from verisoo.crypto import load_private_key
from soosef.paths import IDENTITY_PRIVATE_KEY
if not IDENTITY_PRIVATE_KEY.exists():
return None
return load_private_key(IDENTITY_PRIVATE_KEY)
def _allowed_image(filename: str) -> bool:
if not filename or "." not in filename:
return False
return filename.rsplit(".", 1)[1].lower() in {"png", "jpg", "jpeg", "bmp", "gif", "webp", "tiff", "tif"}
@bp.route("/attest", methods=["GET", "POST"])
def attest():
"""Create a provenance attestation for an image."""
return render_template("attest/attest.html")
from auth import login_required as _lr
# Check identity exists
private_key = _get_private_key()
has_identity = private_key is not None
if request.method == "POST":
if not has_identity:
flash("No identity configured. Run 'soosef init' or generate one from the Keys page.", "error")
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")
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")
return redirect(url_for("attest.attest"))
try:
image_data = image_file.read()
# Build optional metadata
metadata = {}
caption = request.form.get("caption", "").strip()
location_name = request.form.get("location_name", "").strip()
if caption:
metadata["caption"] = caption
if location_name:
metadata["location_name"] = location_name
auto_exif = request.form.get("auto_exif", "on") == "on"
# Create the attestation
from verisoo.attestation import create_attestation
attestation = create_attestation(
image_data=image_data,
private_key=private_key,
metadata=metadata if metadata else None,
auto_exif=auto_exif,
)
# Store in the append-only log
storage = _get_storage()
index = storage.append_record(attestation.record)
# Save our own identity so we can look it up during verification
from verisoo.models import Identity
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
pub_key = private_key.public_key()
pub_bytes = pub_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
identity = Identity(
public_key=pub_bytes,
fingerprint=attestation.record.attestor_fingerprint,
metadata={"name": "SooSeF Local Identity"},
)
try:
storage.save_identity(identity)
except Exception:
pass # May already exist
record = attestation.record
hashes = record.image_hashes
return render_template(
"attest/result.html",
success=True,
record_id=record.record_id,
short_id=record.short_id,
attestor=record.attestor_fingerprint,
timestamp=record.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC"),
sha256=hashes.sha256,
phash=hashes.phash,
dhash=hashes.dhash,
caption=metadata.get("caption", ""),
location_name=metadata.get("location_name", ""),
exif_metadata=record.metadata,
index=index,
filename=image_file.filename,
)
except Exception as e:
flash(f"Attestation failed: {e}", "error")
return redirect(url_for("attest.attest"))
return render_template("attest/attest.html", has_identity=has_identity)
@bp.route("/verify", methods=["GET", "POST"])
def verify():
"""Verify an image against attestation records."""
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")
return redirect(url_for("attest.verify"))
if not _allowed_image(image_file.filename):
flash("Unsupported image format.", "error")
return redirect(url_for("attest.verify"))
try:
image_data = image_file.read()
from verisoo.hashing import hash_image, compute_all_distances, is_same_image
# Compute hashes of the uploaded image
query_hashes = hash_image(image_data)
# Search the attestation log
storage = _get_storage()
stats = storage.get_stats()
if stats.record_count == 0:
return render_template(
"attest/verify_result.html",
found=False,
message="No attestations in the local log yet.",
query_hashes=query_hashes,
filename=image_file.filename,
matches=[],
)
# Search by SHA-256 first (exact match)
matches = []
exact_records = storage.get_records_by_image_sha256(query_hashes.sha256)
for record in exact_records:
matches.append({
"record": record,
"match_type": "exact",
"distances": {},
})
# Then search by perceptual hash if no exact match
if not matches and query_hashes.phash:
all_records = [storage.get_record(i) for i in range(stats.record_count)]
for record in all_records:
same, match_type = is_same_image(
query_hashes, record.image_hashes, perceptual_threshold=10
)
if same:
distances = compute_all_distances(query_hashes, record.image_hashes)
matches.append({
"record": record,
"match_type": match_type or "perceptual",
"distances": distances,
})
# Resolve attestor identities
for match in matches:
record = match["record"]
try:
identity = storage.load_identity(record.attestor_fingerprint)
match["attestor_name"] = identity.metadata.get("name", "Unknown") if identity else "Unknown"
except Exception:
match["attestor_name"] = "Unknown"
return render_template(
"attest/verify_result.html",
found=len(matches) > 0,
message=f"Found {len(matches)} matching attestation(s)." if matches else "No matching attestations found.",
query_hashes=query_hashes,
filename=image_file.filename,
matches=matches,
)
except Exception as e:
flash(f"Verification failed: {e}", "error")
return redirect(url_for("attest.verify"))
return render_template("attest/verify.html")
@bp.route("/attest/record/<record_id>")
def record(record_id):
"""View a single attestation record."""
return render_template("attest/record.html", record_id=record_id)
@bp.route("/attest/log")
def log():
"""List recent attestations."""
return render_template("attest/log.html")
try:
storage = _get_storage()
stats = storage.get_stats()
records = []
# Show last 50 records, newest first
start = max(0, stats.record_count - 50)
for i in range(stats.record_count - 1, start - 1, -1):
try:
record = storage.get_record(i)
records.append({"index": i, "record": record})
except Exception:
continue
return render_template("attest/log.html", records=records, total=stats.record_count)
except Exception as e:
flash(f"Could not read attestation log: {e}", "error")
return render_template("attest/log.html", records=[], total=0)

View File

@ -1,11 +1,60 @@
{% extends "base.html" %}
{% block title %}Attest — SooSeF{% endblock %}
{% block title %}Attest Image — SooSeF{% endblock %}
{% block content %}
<h2><i class="bi bi-patch-check me-2"></i>Attest Image</h2>
<p class="text-muted">Create a cryptographic provenance attestation — prove when, where, and by whom an image was captured.</p>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
Verisoo attestation UI. Upload an image, optionally add metadata (location, caption),
and sign with your Ed25519 identity. The attestation is stored in the local append-only log.
<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>
</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.
</p>
{% if not has_identity %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>No identity configured.</strong> Generate one from the
<a href="/keys" class="alert-link">Keys page</a> or run <code>soosef init</code>.
</div>
{% endif %}
<form method="POST" enctype="multipart/form-data">
<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>
</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">
</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">
</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)
</label>
</div>
<button type="submit" class="btn btn-info btn-lg w-100" {% if not has_identity %}disabled{% endif %}>
<i class="bi bi-patch-check me-2"></i>Create Attestation
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,10 +1,47 @@
{% extends "base.html" %}
{% block title %}Attestation Log — SooSeF{% endblock %}
{% block content %}
<h2><i class="bi bi-journal-text me-2"></i>Attestation Log</h2>
<p class="text-muted">Recent attestations from the local append-only log.</p>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
Lists attestation records with filters by attestor, date range, and verification status.
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4><i class="bi bi-journal-text me-2"></i>Attestation Log</h4>
<span class="badge bg-info">{{ total }} total record{{ 's' if total != 1 }}</span>
</div>
{% if records %}
<div class="table-responsive">
<table class="table table-hover table-dark">
<thead>
<tr>
<th>#</th>
<th>Short ID</th>
<th>Attestor</th>
<th>Timestamp</th>
<th>SHA-256</th>
<th>Caption</th>
</tr>
</thead>
<tbody>
{% for item in records %}
<tr>
<td class="text-muted">{{ item.index }}</td>
<td><code class="text-info">{{ item.record.short_id }}</code></td>
<td><code class="small">{{ item.record.attestor_fingerprint[:12] }}...</code></td>
<td class="small">{{ item.record.timestamp.strftime('%Y-%m-%d %H:%M') }}</td>
<td><code class="small text-warning">{{ item.record.image_hashes.sha256[:16] }}...</code></td>
<td class="small text-muted">{{ item.record.metadata.get('caption', '') | truncate(40) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% 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>.
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}Attestation Created — SooSeF{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<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 }}).
</div>
<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>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label text-muted small">Record ID</label>
<div><code class="text-info">{{ record_id }}</code></div>
</div>
<div class="col-md-6">
<label class="form-label text-muted small">Short ID</label>
<div><code class="text-info">{{ short_id }}</code></div>
</div>
<div class="col-md-6">
<label class="form-label text-muted small">Attestor Fingerprint</label>
<div><code>{{ attestor[:16] }}...</code></div>
</div>
<div class="col-md-6">
<label class="form-label text-muted small">Timestamp</label>
<div>{{ timestamp }}</div>
</div>
</div>
</div>
</div>
<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>
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label text-muted small">SHA-256 (exact identity)</label>
<div><code class="text-warning small">{{ sha256 }}</code></div>
</div>
{% if phash %}
<div class="mb-2">
<label class="form-label text-muted small">pHash (survives JPEG compression)</label>
<div><code class="small">{{ phash }}</code></div>
</div>
{% endif %}
{% if dhash %}
<div class="mb-2">
<label class="form-label text-muted small">dHash (survives resizing)</label>
<div><code class="small">{{ dhash }}</code></div>
</div>
{% endif %}
</div>
</div>
{% if caption or location_name %}
<div class="card bg-dark border-secondary mb-4">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-info-circle me-2"></i>Metadata</h6>
</div>
<div class="card-body">
{% if caption %}
<div class="mb-2">
<label class="form-label text-muted small">Caption</label>
<div>{{ caption }}</div>
</div>
{% endif %}
{% if location_name %}
<div class="mb-2">
<label class="form-label text-muted small">Location</label>
<div>{{ location_name }}</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
<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
</a>
<a href="/attest/log" class="btn btn-outline-secondary">
<i class="bi bi-journal-text me-2"></i>View Attestation Log
</a>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,12 +1,33 @@
{% extends "base.html" %}
{% block title %}Verify — SooSeF{% endblock %}
{% block title %}Verify Image — SooSeF{% endblock %}
{% block content %}
<h2><i class="bi bi-search me-2"></i>Verify Image</h2>
<p class="text-muted">Check an image against attestation records using multi-algorithm hash matching.</p>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
Verisoo verification UI. Upload an image to check against the attestation log.
Uses SHA-256 (exact) and perceptual hashes (pHash, dHash, aHash) for robustness
against compression and resizing.
<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>
</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.
</p>
<form method="POST" enctype="multipart/form-data">
<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>
</div>
<button type="submit" class="btn btn-info btn-lg w-100">
<i class="bi bi-search me-2"></i>Verify Image
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,108 @@
{% extends "base.html" %}
{% block title %}Verification Result — SooSeF{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
{% if found %}
<div class="alert alert-success">
<i class="bi bi-patch-check me-2"></i>
<strong>{{ message }}</strong>
</div>
{% else %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>{{ message }}</strong>
</div>
{% endif %}
{# Query image 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>
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label text-muted small">SHA-256</label>
<div><code class="text-warning small">{{ query_hashes.sha256 }}</code></div>
</div>
{% if query_hashes.phash %}
<div class="mb-2">
<label class="form-label text-muted small">pHash</label>
<div><code class="small">{{ query_hashes.phash }}</code></div>
</div>
{% endif %}
</div>
</div>
{# Matching attestations #}
{% for match in matches %}
<div class="card bg-dark border-{{ 'success' if match.match_type == 'exact' else 'info' }} mb-3">
<div class="card-header d-flex justify-content-between">
<span>
<i class="bi bi-patch-check me-2"></i>
Attestation <code>{{ match.record.short_id }}</code>
</span>
<span class="badge bg-{{ 'success' if match.match_type == 'exact' else 'info' }}">
{{ match.match_type }} match
</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label text-muted small">Attestor</label>
<div>
<code>{{ match.record.attestor_fingerprint[:16] }}...</code>
<span class="text-muted small ms-2">({{ match.attestor_name }})</span>
</div>
</div>
<div class="col-md-6">
<label class="form-label text-muted small">Attested At</label>
<div>{{ match.record.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }}</div>
</div>
{% if match.record.captured_at %}
<div class="col-md-6">
<label class="form-label text-muted small">Captured At</label>
<div>{{ match.record.captured_at.strftime('%Y-%m-%d %H:%M:%S') }}</div>
</div>
{% endif %}
{% if match.record.location %}
<div class="col-md-6">
<label class="form-label text-muted small">Location</label>
<div>{{ match.record.location }}</div>
</div>
{% endif %}
{% if match.record.metadata.get('caption') %}
<div class="col-12">
<label class="form-label text-muted small">Caption</label>
<div>{{ match.record.metadata.caption }}</div>
</div>
{% endif %}
</div>
{% if match.distances %}
<hr class="border-secondary">
<h6 class="text-muted small">Hash Distances</h6>
<div class="d-flex gap-3 flex-wrap">
{% for name, dist in match.distances.items() %}
<span class="badge bg-{{ 'success' if dist == 0 else ('info' if dist < 5 else ('warning' if dist < 10 else 'danger')) }}">
{{ name }}: {{ dist }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
<div class="d-grid gap-2 mt-4">
<a href="/verify" class="btn btn-outline-info">
<i class="bi bi-search me-2"></i>Verify Another Image
</a>
<a href="/attest/log" class="btn btn-outline-secondary">
<i class="bi bi-journal-text me-2"></i>View Attestation Log
</a>
</div>
</div>
</div>
{% endblock %}