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:
@@ -75,6 +75,13 @@ def create_app(config: SoosefConfig | None = None) -> Flask:
|
||||
app.config["HTTPS_ENABLED"] = config.https_enabled
|
||||
app.config["SOOSEF_CONFIG"] = config
|
||||
|
||||
# Point temp_storage at ~/.soosef/temp/ before any routes run, so all
|
||||
# uploaded files land where the killswitch's destroy_temp_files step
|
||||
# expects them. Must happen after ensure_dirs() so the directory exists.
|
||||
import temp_storage as _ts
|
||||
|
||||
_ts.init(TEMP_DIR)
|
||||
|
||||
# Persist secret key so sessions survive restarts
|
||||
_load_secret_key(app)
|
||||
|
||||
@@ -239,6 +246,7 @@ def _register_stegasoo_routes(app: Flask) -> None:
|
||||
The stegasoo templates are in templates/stego/ and extend our base.html.
|
||||
"""
|
||||
import temp_storage
|
||||
from soosef.audit import log_action
|
||||
from subprocess_stego import (
|
||||
SubprocessStego,
|
||||
cleanup_progress_file,
|
||||
@@ -460,6 +468,14 @@ def _register_stegasoo_routes(app: Flask) -> None:
|
||||
username = request.form.get("username", "")
|
||||
temp_password = generate_temp_password()
|
||||
success, message = create_user(username, temp_password)
|
||||
log_action(
|
||||
actor=get_username(),
|
||||
action="user.create",
|
||||
target=f"user:{username}",
|
||||
outcome="success" if success else "failure",
|
||||
source="web",
|
||||
detail=None if success else message,
|
||||
)
|
||||
if success:
|
||||
flash(f"User '{username}' created with temporary password: {temp_password}", "success")
|
||||
else:
|
||||
@@ -470,7 +486,17 @@ def _register_stegasoo_routes(app: Flask) -> None:
|
||||
@app.route("/admin/users/<int:user_id>/delete", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_delete_user(user_id):
|
||||
success, message = delete_user(user_id)
|
||||
target_user = get_user_by_id(user_id)
|
||||
target_name = target_user.username if target_user else str(user_id)
|
||||
success, message = delete_user(user_id, get_current_user().id)
|
||||
log_action(
|
||||
actor=get_username(),
|
||||
action="user.delete",
|
||||
target=f"user:{target_name}",
|
||||
outcome="success" if success else "failure",
|
||||
source="web",
|
||||
detail=None if success else message,
|
||||
)
|
||||
flash(message, "success" if success else "error")
|
||||
return redirect(url_for("admin_users"))
|
||||
|
||||
@@ -479,9 +505,18 @@ def _register_stegasoo_routes(app: Flask) -> None:
|
||||
def admin_reset_password(user_id):
|
||||
temp_password = generate_temp_password()
|
||||
success, message = reset_user_password(user_id, temp_password)
|
||||
target_user = get_user_by_id(user_id)
|
||||
target_name = target_user.username if target_user else str(user_id)
|
||||
log_action(
|
||||
actor=get_username(),
|
||||
action="user.password_reset",
|
||||
target=f"user:{target_name}",
|
||||
outcome="success" if success else "failure",
|
||||
source="web",
|
||||
detail=None if success else message,
|
||||
)
|
||||
if success:
|
||||
user = get_user_by_id(user_id)
|
||||
flash(f"Password for '{user.username}' reset to: {temp_password}", "success")
|
||||
flash(f"Password for '{target_name}' reset to: {temp_password}", "success")
|
||||
else:
|
||||
flash(message, "error")
|
||||
return redirect(url_for("admin_users"))
|
||||
@@ -530,7 +565,7 @@ def _register_stegasoo_routes(app: Flask) -> None:
|
||||
|
||||
if not qr_too_large:
|
||||
qr_token = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
temp_storage.cleanup_expired(TEMP_FILE_EXPIRY)
|
||||
temp_storage.save_temp_file(
|
||||
qr_token,
|
||||
creds.rsa_key_pem.encode(),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -9,17 +9,26 @@ Files are stored in a temp directory with:
|
||||
- {file_id}.data - The actual file data
|
||||
- {file_id}.json - Metadata (filename, timestamp, mime_type, etc.)
|
||||
|
||||
IMPORTANT: This module ONLY manages files in the temp_files/ directory.
|
||||
IMPORTANT: This module ONLY manages files in the temp directory.
|
||||
It does NOT touch instance/ (auth database) or any other directories.
|
||||
|
||||
All temp files are written to ~/.soosef/temp/ (soosef.paths.TEMP_DIR) so
|
||||
that the killswitch's destroy_temp_files step covers them.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
# Default temp directory (can be overridden)
|
||||
DEFAULT_TEMP_DIR = Path(__file__).parent / "temp_files"
|
||||
import soosef.paths as paths
|
||||
|
||||
# Default temp directory — always under ~/.soosef/temp/ so the killswitch
|
||||
# (which purges paths.TEMP_DIR) can reach every file written here.
|
||||
DEFAULT_TEMP_DIR: Path = paths.TEMP_DIR
|
||||
|
||||
# Lock for thread-safe operations
|
||||
_lock = Lock()
|
||||
@@ -28,7 +37,7 @@ _lock = Lock()
|
||||
_temp_dir: Path = DEFAULT_TEMP_DIR
|
||||
|
||||
|
||||
def init(temp_dir: Path | str | None = None):
|
||||
def init(temp_dir: Path | str | None = None) -> None:
|
||||
"""Initialize temp storage with optional custom directory."""
|
||||
global _temp_dir
|
||||
_temp_dir = Path(temp_dir) if temp_dir else DEFAULT_TEMP_DIR
|
||||
@@ -50,6 +59,35 @@ def _thumb_path(thumb_id: str) -> Path:
|
||||
return _temp_dir / f"{thumb_id}.thumb"
|
||||
|
||||
|
||||
def _secure_delete(path: Path) -> None:
|
||||
"""Overwrite and delete a file. Best-effort on flash storage."""
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
if platform.system() == "Linux":
|
||||
try:
|
||||
subprocess.run(
|
||||
["shred", "-u", "-z", "-n", "3", str(path)],
|
||||
timeout=30,
|
||||
capture_output=True,
|
||||
)
|
||||
return
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# Fallback: overwrite with zeros then delete
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
with open(path, "r+b") as f:
|
||||
f.write(b"\x00" * size)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
path.unlink()
|
||||
except OSError:
|
||||
# Last resort: plain unlink so we don't leave data stranded
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def save_temp_file(file_id: str, data: bytes, metadata: dict) -> None:
|
||||
"""
|
||||
Save a temp file with its metadata.
|
||||
@@ -103,12 +141,12 @@ def has_temp_file(file_id: str) -> bool:
|
||||
|
||||
|
||||
def delete_temp_file(file_id: str) -> None:
|
||||
"""Delete a temp file and its metadata."""
|
||||
"""Securely delete a temp file and its metadata."""
|
||||
init()
|
||||
|
||||
with _lock:
|
||||
_data_path(file_id).unlink(missing_ok=True)
|
||||
_meta_path(file_id).unlink(missing_ok=True)
|
||||
_secure_delete(_data_path(file_id))
|
||||
_secure_delete(_meta_path(file_id))
|
||||
|
||||
|
||||
def save_thumbnail(thumb_id: str, data: bytes) -> None:
|
||||
@@ -134,16 +172,16 @@ def get_thumbnail(thumb_id: str) -> bytes | None:
|
||||
|
||||
|
||||
def delete_thumbnail(thumb_id: str) -> None:
|
||||
"""Delete a thumbnail."""
|
||||
"""Securely delete a thumbnail."""
|
||||
init()
|
||||
|
||||
with _lock:
|
||||
_thumb_path(thumb_id).unlink(missing_ok=True)
|
||||
_secure_delete(_thumb_path(thumb_id))
|
||||
|
||||
|
||||
def cleanup_expired(max_age_seconds: float) -> int:
|
||||
"""
|
||||
Delete expired temp files.
|
||||
Securely delete expired temp files.
|
||||
|
||||
Args:
|
||||
max_age_seconds: Maximum age in seconds before expiry
|
||||
@@ -165,14 +203,14 @@ def cleanup_expired(max_age_seconds: float) -> int:
|
||||
|
||||
if now - timestamp > max_age_seconds:
|
||||
file_id = meta_file.stem
|
||||
_data_path(file_id).unlink(missing_ok=True)
|
||||
meta_file.unlink(missing_ok=True)
|
||||
_secure_delete(_data_path(file_id))
|
||||
_secure_delete(meta_file)
|
||||
# Also delete thumbnail if exists
|
||||
_thumb_path(f"{file_id}_thumb").unlink(missing_ok=True)
|
||||
_secure_delete(_thumb_path(f"{file_id}_thumb"))
|
||||
deleted += 1
|
||||
except (OSError, json.JSONDecodeError):
|
||||
# Remove corrupted files
|
||||
meta_file.unlink(missing_ok=True)
|
||||
_secure_delete(meta_file)
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
@@ -180,7 +218,7 @@ def cleanup_expired(max_age_seconds: float) -> int:
|
||||
|
||||
def cleanup_all() -> int:
|
||||
"""
|
||||
Delete all temp files. Call on service start/stop.
|
||||
Securely delete all temp files. Call on service start/stop.
|
||||
|
||||
Returns:
|
||||
Number of files deleted
|
||||
@@ -192,7 +230,7 @@ def cleanup_all() -> int:
|
||||
with _lock:
|
||||
for f in _temp_dir.iterdir():
|
||||
if f.is_file():
|
||||
f.unlink(missing_ok=True)
|
||||
_secure_delete(f)
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
|
||||
@@ -95,12 +95,35 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if found %}
|
||||
<div class="card bg-dark border-secondary mt-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Download Verification Receipt</h6>
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
<form action="/verify/receipt" method="post" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<input class="form-control form-control-sm bg-dark text-light border-secondary"
|
||||
type="file" name="image" accept="image/*" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-warning btn-sm">
|
||||
Download Receipt (.json)
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<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
|
||||
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
|
||||
View Attestation Log
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user