Fix 14 bugs and add features from power-user security audit

Critical fixes:
- Fix admin_delete_user missing current_user_id argument (TypeError on every delete)
- Fix self-signed cert OOM: bytes(2130706433) → IPv4Address("127.0.0.1")
- Add @login_required to attestation routes (attest, log); verify stays public
- Add auth guards to fieldkit (@admin_required on killswitch) and keys blueprints
- Fix cleanup_temp_files NameError in generate() route

Security hardening:
- Unify temp storage to ~/.soosef/temp/ so killswitch purge covers web uploads
- Replace Path.unlink() with secure deletion (shred fallback) in temp_storage
- Add structured audit log (audit.jsonl) for admin, key, and killswitch actions

New features:
- Dead man's switch background enforcement thread in serve + check-deadman CLI
- Key rotation: soosef keys rotate-identity/rotate-channel with archiving
- Batch attestation: soosef attest batch <dir> with progress and error handling
- Geofence CLI: set/check/clear commands with config persistence
- USB CLI: snapshot/check commands against device whitelist
- Verification receipt download (/verify/receipt JSON endpoint + UI button)
- IdentityInfo.created_at populated from sidecar meta.json (mtime fallback)

Data layer:
- ChainStore.get() now O(1) via byte-offset index built during state rebuild
- Add federation module (chain, models, serialization, entropy)

Includes 45+ new tests across chain, deadman, key rotation, killswitch, and
serialization modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-04-01 17:06:33 -04:00
parent fb2e036e66
commit 51c9b0a99a
28 changed files with 3749 additions and 168 deletions

View File

@@ -4,15 +4,18 @@ Attestation blueprint — attest and verify images via Verisoo.
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
- Verification receipt: same as verify but returns a downloadable JSON file
"""
from __future__ import annotations
import io
import json
import socket
from datetime import UTC, datetime
from pathlib import Path
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask import Blueprint, Response, flash, redirect, render_template, request, url_for
from auth import login_required
bp = Blueprint("attest", __name__)
@@ -35,24 +38,80 @@ def _get_private_key():
return load_private_key(IDENTITY_PRIVATE_KEY)
def _wrap_in_chain(verisoo_record, private_key, metadata: dict | None = None):
"""Wrap a Verisoo attestation record in the hash chain.
Returns the chain record, or None if chain is disabled.
"""
import hashlib
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from soosef.config import SoosefConfig
from soosef.federation.chain import ChainStore
from soosef.paths import CHAIN_DIR, IDENTITY_PRIVATE_KEY
config = SoosefConfig.load()
if not config.chain_enabled or not config.chain_auto_wrap:
return None
# Hash the verisoo record bytes as chain content
record_bytes = (
verisoo_record.to_bytes()
if hasattr(verisoo_record, "to_bytes")
else str(verisoo_record).encode()
)
content_hash = hashlib.sha256(record_bytes).digest()
# Load Ed25519 key for chain signing (need the cryptography key, not verisoo's)
priv_pem = IDENTITY_PRIVATE_KEY.read_bytes()
chain_private_key = load_pem_private_key(priv_pem, password=None)
chain_metadata = {}
if metadata:
if "caption" in metadata:
chain_metadata["caption"] = metadata["caption"]
if "location_name" in metadata:
chain_metadata["location"] = metadata["location_name"]
store = ChainStore(CHAIN_DIR)
return store.append(
content_hash=content_hash,
content_type="verisoo/attestation-v1",
private_key=chain_private_key,
metadata=chain_metadata,
)
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"}
return filename.rsplit(".", 1)[1].lower() in {
"png",
"jpg",
"jpeg",
"bmp",
"gif",
"webp",
"tiff",
"tif",
}
@bp.route("/attest", methods=["GET", "POST"])
@login_required
def attest():
"""Create a provenance attestation for an image."""
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")
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")
@@ -92,6 +151,19 @@ def attest():
storage = _get_storage()
index = storage.append_record(attestation.record)
# Wrap in hash chain if enabled
chain_record = None
try:
chain_record = _wrap_in_chain(attestation.record, private_key, metadata)
except Exception as e:
import logging
logging.getLogger(__name__).warning("Chain wrapping failed: %s", e)
flash(
"Attestation saved, but chain wrapping failed. " "Check chain configuration.",
"warning",
)
# 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
@@ -126,6 +198,7 @@ def attest():
exif_metadata=record.metadata,
index=index,
filename=image_file.filename,
chain_index=chain_record.chain_index if chain_record else None,
)
except Exception as e:
@@ -135,9 +208,67 @@ def attest():
return render_template("attest/attest.html", has_identity=has_identity)
def _verify_image(image_data: bytes) -> dict:
"""Run the full verification pipeline against the attestation log.
Returns a dict with keys:
query_hashes — ImageHashes object from verisoo
matches — list of match dicts (record, match_type, distances, attestor_name)
record_count — total records searched
"""
from verisoo.hashing import compute_all_distances, hash_image, is_same_image
query_hashes = hash_image(image_data)
storage = _get_storage()
stats = storage.get_stats()
if stats.record_count == 0:
return {"query_hashes": query_hashes, "matches": [], "record_count": 0}
# Exact SHA-256 match first
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": {}})
# Perceptual fallback
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:
try:
identity = storage.load_identity(match["record"].attestor_fingerprint)
match["attestor_name"] = (
identity.metadata.get("name", "Unknown") if identity else "Unknown"
)
except Exception:
match["attestor_name"] = "Unknown"
return {"query_hashes": query_hashes, "matches": matches, "record_count": stats.record_count}
@bp.route("/verify", methods=["GET", "POST"])
def verify():
"""Verify an image against attestation records."""
"""Verify an image against attestation records.
Intentionally unauthenticated: third parties (editors, fact-checkers, courts)
must be able to verify provenance without having an account on this instance.
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:
@@ -149,18 +280,11 @@ def verify():
return redirect(url_for("attest.verify"))
try:
image_data = image_file.read()
result = _verify_image(image_file.read())
query_hashes = result["query_hashes"]
matches = result["matches"]
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:
if result["record_count"] == 0:
return render_template(
"attest/verify_result.html",
found=False,
@@ -170,44 +294,14 @@ def verify():
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.",
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,
@@ -220,7 +314,110 @@ def verify():
return render_template("attest/verify.html")
@bp.route("/verify/receipt", methods=["POST"])
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,
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:
return Response(
json.dumps({"error": "No image provided"}),
status=400,
mimetype="application/json",
)
if not _allowed_image(image_file.filename):
return Response(
json.dumps({"error": "Unsupported image format"}),
status=400,
mimetype="application/json",
)
try:
result = _verify_image(image_file.read())
except Exception as e:
return Response(
json.dumps({"error": f"Verification failed: {e}"}),
status=500,
mimetype="application/json",
)
query_hashes = result["query_hashes"]
matches = result["matches"]
verification_ts = datetime.now(UTC).isoformat()
try:
verifier_instance = socket.gethostname()
except Exception:
verifier_instance = "unknown"
matching_records = []
for match in matches:
record = match["record"]
rec_entry: dict = {
"match_type": match["match_type"],
"attestor_fingerprint": record.attestor_fingerprint,
"attestor_name": match.get("attestor_name", "Unknown"),
"attested_at": record.timestamp.isoformat() if record.timestamp else None,
"record_id": str(record.record_id),
"short_id": str(record.short_id) if hasattr(record, "short_id") else None,
}
# Include perceptual hash distances when present (perceptual matches only)
if match.get("distances"):
rec_entry["hash_distances"] = {k: int(v) for k, v in match["distances"].items()}
# Optional fields
if getattr(record, "captured_at", None):
rec_entry["captured_at"] = record.captured_at.isoformat()
if getattr(record, "location", None):
rec_entry["location"] = record.location
if getattr(record, "metadata", None):
# Exclude any key material — only human-readable metadata
safe_meta = {
k: v
for k, v in record.metadata.items()
if k in ("caption", "location_name", "device", "software")
}
if safe_meta:
rec_entry["metadata"] = safe_meta
matching_records.append(rec_entry)
receipt = {
"schema_version": "1",
"verification_timestamp": verification_ts,
"verifier_instance": verifier_instance,
"queried_filename": image_file.filename,
"image_hash": {
"sha256": query_hashes.sha256,
"phash": query_hashes.phash,
"dhash": getattr(query_hashes, "dhash", None),
},
"records_searched": result["record_count"],
"matches_found": len(matching_records),
"matching_records": matching_records,
}
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
)
download_name = f"receipt_{safe_filename}_{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}.json"
return Response(
receipt_json,
status=200,
mimetype="application/json",
headers={"Content-Disposition": f'attachment; filename="{download_name}"'},
)
@bp.route("/attest/log")
@login_required
def log():
"""List recent attestations."""
try:

View File

@@ -4,10 +4,14 @@ Fieldkit blueprint — killswitch, dead man's switch, status dashboard.
from flask import Blueprint, flash, redirect, render_template, request, url_for
from auth import admin_required, get_username, login_required
from soosef.audit import log_action
bp = Blueprint("fieldkit", __name__, url_prefix="/fieldkit")
@bp.route("/")
@login_required
def status():
"""Fieldkit status dashboard — all monitors and system health."""
from soosef.fieldkit.deadman import DeadmanSwitch
@@ -20,6 +24,7 @@ def status():
@bp.route("/killswitch", methods=["GET", "POST"])
@admin_required
def killswitch():
"""Killswitch arming and firing UI."""
if request.method == "POST":
@@ -27,7 +32,22 @@ def killswitch():
if action == "fire" and request.form.get("confirm") == "CONFIRM-PURGE":
from soosef.fieldkit.killswitch import PurgeScope, execute_purge
actor = get_username()
result = execute_purge(PurgeScope.ALL, reason="web_ui")
outcome = "success" if result.fully_purged else "failure"
failed_steps = ", ".join(name for name, _ in result.steps_failed)
log_action(
actor=actor,
action="killswitch.fire",
target="all",
outcome=outcome,
source="web",
detail=(
f"steps_completed={len(result.steps_completed)} "
f"steps_failed={len(result.steps_failed)}"
+ (f" failed={failed_steps}" if failed_steps else "")
),
)
flash(
f"Purge executed: {len(result.steps_completed)} steps completed, "
f"{len(result.steps_failed)} failed",
@@ -39,6 +59,7 @@ def killswitch():
@bp.route("/deadman/checkin", methods=["POST"])
@login_required
def deadman_checkin():
"""Record a dead man's switch check-in."""
from soosef.fieldkit.deadman import DeadmanSwitch

View File

@@ -4,10 +4,14 @@ Key management blueprint — unified view of all key material.
from flask import Blueprint, flash, redirect, render_template, request, url_for
from auth import get_username, login_required
from soosef.audit import log_action
bp = Blueprint("keys", __name__, url_prefix="/keys")
@bp.route("/")
@login_required
def index():
"""Key management dashboard."""
from soosef.keystore import KeystoreManager
@@ -17,22 +21,60 @@ def index():
@bp.route("/channel/generate", methods=["POST"])
@login_required
def generate_channel():
"""Generate a new channel key."""
from soosef.keystore import KeystoreManager
ks = KeystoreManager()
key = ks.generate_channel_key()
flash(f"Channel key generated: {key[:8]}...", "success")
try:
key = ks.generate_channel_key()
log_action(
actor=get_username(),
action="key.channel.generate",
target=f"channel:{key[:8]}",
outcome="success",
source="web",
)
flash(f"Channel key generated: {key[:8]}...", "success")
except Exception as exc:
log_action(
actor=get_username(),
action="key.channel.generate",
target="channel",
outcome="failure",
source="web",
detail=str(exc),
)
flash(f"Channel key generation failed: {exc}", "error")
return redirect(url_for("keys.index"))
@bp.route("/identity/generate", methods=["POST"])
@login_required
def generate_identity():
"""Generate a new Ed25519 identity."""
from soosef.keystore import KeystoreManager
ks = KeystoreManager()
info = ks.generate_identity()
flash(f"Identity generated: {info.fingerprint[:16]}...", "success")
try:
info = ks.generate_identity()
log_action(
actor=get_username(),
action="key.identity.generate",
target=f"identity:{info.fingerprint[:16]}",
outcome="success",
source="web",
)
flash(f"Identity generated: {info.fingerprint[:16]}...", "success")
except Exception as exc:
log_action(
actor=get_username(),
action="key.identity.generate",
target="identity",
outcome="failure",
source="web",
detail=str(exc),
)
flash(f"Identity generation failed: {exc}", "error")
return redirect(url_for("keys.index"))