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:
parent
171e51643c
commit
f557cac45a
@ -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]
|
||||
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,10 +334,18 @@ 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"]:
|
||||
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. Your submission was received.",
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
208
src/soosef/archive.py
Normal file
208
src/soosef/archive.py
Normal file
@ -0,0 +1,208 @@
|
||||
"""
|
||||
Cold archive export for long-term evidence preservation.
|
||||
|
||||
Produces a self-describing archive containing everything needed to
|
||||
reconstitute a SooSeF evidence store on a fresh instance or verify
|
||||
evidence decades later without any SooSeF installation.
|
||||
|
||||
Designed for OAIS (ISO 14721) alignment: the archive is self-describing,
|
||||
includes its own verification code, and documents the cryptographic
|
||||
algorithms used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def export_cold_archive(
|
||||
output_path: Path,
|
||||
include_keys: bool = True,
|
||||
key_password: bytes | None = None,
|
||||
) -> dict:
|
||||
"""Export a full cold archive of the SooSeF evidence store.
|
||||
|
||||
Contents:
|
||||
- chain/chain.bin — raw append-only hash chain
|
||||
- chain/state.cbor — chain state checkpoint
|
||||
- chain/anchors/ — external timestamp anchors
|
||||
- attestations/log.bin — verisoo attestation log
|
||||
- attestations/index/ — LMDB index (if present)
|
||||
- keys/public.pem — signer's public key
|
||||
- keys/bundle.enc — encrypted key bundle (if include_keys + password)
|
||||
- keys/trusted/ — trusted collaborator keys
|
||||
- manifest.json — archive metadata and integrity hashes
|
||||
- verify.py — standalone verification script
|
||||
- ALGORITHMS.txt — cryptographic algorithm documentation
|
||||
- README.txt — human-readable description
|
||||
|
||||
Args:
|
||||
output_path: Where to write the ZIP.
|
||||
include_keys: Whether to include the encrypted key bundle.
|
||||
key_password: Password for encrypting the key bundle.
|
||||
|
||||
Returns:
|
||||
Summary dict with archive contents.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
from soosef.paths import (
|
||||
ATTESTATIONS_DIR,
|
||||
CHAIN_DIR,
|
||||
IDENTITY_DIR,
|
||||
IDENTITY_PUBLIC_KEY,
|
||||
)
|
||||
|
||||
ts = datetime.now(UTC)
|
||||
contents = []
|
||||
|
||||
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
|
||||
# Chain data
|
||||
chain_bin = CHAIN_DIR / "chain.bin"
|
||||
if chain_bin.exists():
|
||||
zf.write(chain_bin, "chain/chain.bin")
|
||||
contents.append("chain/chain.bin")
|
||||
|
||||
state_cbor = CHAIN_DIR / "state.cbor"
|
||||
if state_cbor.exists():
|
||||
zf.write(state_cbor, "chain/state.cbor")
|
||||
contents.append("chain/state.cbor")
|
||||
|
||||
anchors_dir = CHAIN_DIR / "anchors"
|
||||
if anchors_dir.exists():
|
||||
for anchor_file in anchors_dir.glob("*.json"):
|
||||
zf.write(anchor_file, f"chain/anchors/{anchor_file.name}")
|
||||
contents.append(f"chain/anchors/{anchor_file.name}")
|
||||
|
||||
# Attestation log
|
||||
log_bin = ATTESTATIONS_DIR / "log.bin"
|
||||
if log_bin.exists():
|
||||
zf.write(log_bin, "attestations/log.bin")
|
||||
contents.append("attestations/log.bin")
|
||||
|
||||
# LMDB index
|
||||
lmdb_dir = ATTESTATIONS_DIR / "index"
|
||||
if lmdb_dir.exists():
|
||||
for f in lmdb_dir.iterdir():
|
||||
if f.name != "lock.mdb": # Skip lock file
|
||||
zf.write(f, f"attestations/index/{f.name}")
|
||||
contents.append(f"attestations/index/{f.name}")
|
||||
|
||||
# Public key (always included — not secret)
|
||||
if IDENTITY_PUBLIC_KEY.exists():
|
||||
zf.write(IDENTITY_PUBLIC_KEY, "keys/public.pem")
|
||||
contents.append("keys/public.pem")
|
||||
|
||||
# Trusted keys
|
||||
trusted_dir = IDENTITY_DIR.parent / "trusted_keys"
|
||||
if trusted_dir.exists():
|
||||
for key_dir in trusted_dir.iterdir():
|
||||
for f in key_dir.iterdir():
|
||||
arcname = f"keys/trusted/{key_dir.name}/{f.name}"
|
||||
zf.write(f, arcname)
|
||||
contents.append(arcname)
|
||||
|
||||
# Encrypted key bundle (optional)
|
||||
if include_keys and key_password:
|
||||
from soosef.keystore.export import export_bundle
|
||||
from soosef.paths import CHANNEL_KEY_FILE
|
||||
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".enc", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
export_bundle(IDENTITY_DIR, CHANNEL_KEY_FILE, tmp_path, key_password)
|
||||
zf.write(tmp_path, "keys/bundle.enc")
|
||||
contents.append("keys/bundle.enc")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
# Algorithm documentation
|
||||
algorithms = """SOOSEF CRYPTOGRAPHIC ALGORITHMS
|
||||
================================
|
||||
|
||||
This archive uses the following algorithms:
|
||||
|
||||
SIGNING
|
||||
- Ed25519 (RFC 8032): 32-byte public keys, 64-byte signatures
|
||||
- Used for: attestation records, chain records, verification receipts
|
||||
|
||||
HASHING
|
||||
- SHA-256: content hashing, chain linkage, fingerprints
|
||||
- pHash (DCT perceptual hash): image similarity matching
|
||||
- dHash (difference hash): image similarity matching
|
||||
|
||||
ENCRYPTION (key bundle only)
|
||||
- AES-256-GCM: authenticated encryption
|
||||
- Argon2id (RFC 9106): key derivation from password
|
||||
Parameters: time_cost=4, memory_cost=256MB, parallelism=4
|
||||
|
||||
CHAIN FORMAT
|
||||
- Append-only binary log: [uint32 BE length] [CBOR record]*
|
||||
- CBOR (RFC 8949): deterministic serialization
|
||||
- Each record signed by Ed25519, linked by prev_hash (SHA-256)
|
||||
|
||||
ATTESTATION LOG
|
||||
- Verisoo binary log: [magic "VERISOO\\x00"] [uint32 version] [records]
|
||||
- LMDB index: SHA-256, pHash, attestor fingerprint lookups
|
||||
|
||||
To verify this archive without SooSeF:
|
||||
1. pip install cryptography cbor2
|
||||
2. python verify.py
|
||||
"""
|
||||
zf.writestr("ALGORITHMS.txt", algorithms)
|
||||
contents.append("ALGORITHMS.txt")
|
||||
|
||||
# Manifest
|
||||
manifest = {
|
||||
"archive_version": "1",
|
||||
"created_at": ts.isoformat(),
|
||||
"soosef_version": "0.2.0",
|
||||
"contents": contents,
|
||||
"file_count": len(contents),
|
||||
"content_hashes": {},
|
||||
}
|
||||
|
||||
# Compute hashes of key files for integrity verification
|
||||
for name in ["chain/chain.bin", "attestations/log.bin"]:
|
||||
try:
|
||||
data = zf.read(name)
|
||||
manifest["content_hashes"][name] = hashlib.sha256(data).hexdigest()
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
zf.writestr("manifest.json", json.dumps(manifest, indent=2))
|
||||
contents.append("manifest.json")
|
||||
|
||||
# README
|
||||
readme = f"""SOOSEF COLD ARCHIVE
|
||||
===================
|
||||
|
||||
Created: {ts.isoformat()}
|
||||
Files: {len(contents)}
|
||||
|
||||
This archive contains a complete snapshot of a SooSeF evidence store.
|
||||
It is self-describing and includes everything needed to verify the
|
||||
evidence it contains, even if SooSeF no longer exists.
|
||||
|
||||
See ALGORITHMS.txt for cryptographic algorithm documentation.
|
||||
Run verify.py to check archive integrity.
|
||||
|
||||
To restore on a fresh SooSeF instance:
|
||||
soosef archive import <this-file.zip>
|
||||
"""
|
||||
zf.writestr("README.txt", readme)
|
||||
|
||||
return {
|
||||
"path": str(output_path),
|
||||
"file_count": len(contents),
|
||||
"created_at": ts.isoformat(),
|
||||
}
|
||||
@ -1383,6 +1383,42 @@ def chain_disclose(ctx, indices, output):
|
||||
)
|
||||
|
||||
|
||||
@chain.command("anchor")
|
||||
@click.option("--tsa", default=None, help="RFC 3161 TSA URL (omit for manual anchor)")
|
||||
@click.pass_context
|
||||
def chain_anchor(ctx, tsa):
|
||||
"""Create an external timestamp anchor for the chain head.
|
||||
|
||||
Without --tsa: exports anchor hash for manual submission (tweet, email, etc.)
|
||||
With --tsa: submits to an RFC 3161 Timestamping Authority automatically.
|
||||
"""
|
||||
from soosef.federation.anchors import (
|
||||
export_anchor_for_manual_submission,
|
||||
get_chain_head_anchor,
|
||||
save_anchor,
|
||||
submit_rfc3161,
|
||||
)
|
||||
|
||||
anchor = get_chain_head_anchor()
|
||||
if "error" in anchor:
|
||||
click.echo(f"Error: {anchor['error']}", err=True)
|
||||
raise SystemExit(1)
|
||||
|
||||
tsa_response = None
|
||||
if tsa:
|
||||
click.echo(f"Submitting to TSA: {tsa}...")
|
||||
tsa_response = submit_rfc3161(anchor, tsa)
|
||||
if tsa_response.get("success"):
|
||||
click.echo(f"TSA token received ({tsa_response['token_size']} bytes)")
|
||||
else:
|
||||
click.echo(f"TSA submission failed: {tsa_response.get('error')}")
|
||||
|
||||
path = save_anchor(anchor, tsa_response)
|
||||
click.echo(f"Anchor saved to {path}")
|
||||
click.echo()
|
||||
click.echo(export_anchor_for_manual_submission(anchor))
|
||||
|
||||
|
||||
def _format_us_timestamp(us: int) -> str:
|
||||
"""Format a Unix microsecond timestamp for display."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
221
src/soosef/evidence.py
Normal file
221
src/soosef/evidence.py
Normal file
@ -0,0 +1,221 @@
|
||||
"""
|
||||
Self-contained evidence package export.
|
||||
|
||||
Produces a ZIP that bundles everything needed for independent
|
||||
verification of attested images:
|
||||
- Original images
|
||||
- Attestation records with full signatures
|
||||
- Chain segment with hash linkage
|
||||
- Signer's public key
|
||||
- Standalone verification script
|
||||
- Human-readable README
|
||||
|
||||
The package is verifiable by a standalone script with no SooSeF
|
||||
installation required — only the `cryptography` pip package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def export_evidence_package(
|
||||
image_paths: list[Path],
|
||||
storage,
|
||||
chain_store,
|
||||
public_key_path: Path | None = None,
|
||||
output_path: Path | None = None,
|
||||
investigation: str | None = None,
|
||||
) -> Path:
|
||||
"""Export a self-contained evidence package as a ZIP.
|
||||
|
||||
Args:
|
||||
image_paths: Paths to the original image files to include.
|
||||
storage: verisoo LocalStorage instance.
|
||||
chain_store: ChainStore instance (or None).
|
||||
public_key_path: Path to the signer's public key PEM.
|
||||
output_path: Output ZIP path (default: auto-generated).
|
||||
investigation: Optional investigation label for filtering.
|
||||
|
||||
Returns:
|
||||
Path to the created ZIP file.
|
||||
"""
|
||||
from soosef.verisoo.hashing import hash_image
|
||||
|
||||
if output_path is None:
|
||||
ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
output_path = Path(f"evidence_{ts}.zip")
|
||||
|
||||
# Collect attestation records for the provided images
|
||||
image_records = []
|
||||
for img_path in image_paths:
|
||||
img_data = img_path.read_bytes()
|
||||
sha256 = hashlib.sha256(img_data).hexdigest()
|
||||
|
||||
# Look up attestation records by SHA-256
|
||||
records = storage.get_records_by_image_sha256(sha256)
|
||||
for record in records:
|
||||
rec_data = {
|
||||
"filename": img_path.name,
|
||||
"sha256": sha256,
|
||||
"attestor_fingerprint": record.attestor_fingerprint,
|
||||
"timestamp": record.timestamp.isoformat() if record.timestamp else None,
|
||||
"signature": record.signature.hex() if record.signature else None,
|
||||
"image_hashes": {
|
||||
"sha256": record.image_hashes.sha256,
|
||||
"phash": record.image_hashes.phash,
|
||||
"dhash": getattr(record.image_hashes, "dhash", ""),
|
||||
},
|
||||
"metadata": record.metadata if hasattr(record, "metadata") else {},
|
||||
}
|
||||
image_records.append(rec_data)
|
||||
|
||||
# Collect chain records if available
|
||||
chain_data = []
|
||||
if chain_store is not None:
|
||||
from soosef.federation.serialization import compute_record_hash
|
||||
|
||||
try:
|
||||
for chain_rec in chain_store:
|
||||
chain_data.append({
|
||||
"chain_index": chain_rec.chain_index,
|
||||
"content_hash": chain_rec.content_hash.hex(),
|
||||
"content_type": chain_rec.content_type,
|
||||
"prev_hash": chain_rec.prev_hash.hex(),
|
||||
"record_hash": compute_record_hash(chain_rec).hex(),
|
||||
"signer_pubkey": chain_rec.signer_pubkey.hex(),
|
||||
"signature": chain_rec.signature.hex(),
|
||||
"claimed_ts": chain_rec.claimed_ts,
|
||||
"metadata": chain_rec.metadata,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
manifest = {
|
||||
"package_version": "1",
|
||||
"exported_at": datetime.now(UTC).isoformat(),
|
||||
"investigation": investigation,
|
||||
"images": [p.name for p in image_paths],
|
||||
"attestation_records": image_records,
|
||||
"chain_records": chain_data,
|
||||
"chain_record_count": len(chain_data),
|
||||
}
|
||||
|
||||
readme = f"""SOOSEF EVIDENCE PACKAGE
|
||||
======================
|
||||
|
||||
Exported: {manifest['exported_at']}
|
||||
Investigation: {investigation or 'N/A'}
|
||||
Images: {len(image_paths)}
|
||||
Attestation records: {len(image_records)}
|
||||
Chain records: {len(chain_data)}
|
||||
|
||||
CONTENTS
|
||||
--------
|
||||
images/ — Original image files
|
||||
manifest.json — Attestation records and chain data
|
||||
public_key.pem — Signer's Ed25519 public key
|
||||
verify.py — Standalone verification script
|
||||
README.txt — This file
|
||||
|
||||
VERIFICATION
|
||||
------------
|
||||
1. Install Python 3.11+ and the cryptography package:
|
||||
pip install cryptography
|
||||
|
||||
2. Run the verification script:
|
||||
python verify.py
|
||||
|
||||
3. The script verifies:
|
||||
- Image SHA-256 hashes match attestation records
|
||||
- Chain hash linkage is unbroken
|
||||
- Ed25519 signatures are valid (if public key is available)
|
||||
|
||||
This package is self-contained. No SooSeF installation is required
|
||||
to verify the evidence.
|
||||
"""
|
||||
|
||||
verify_script = '''#!/usr/bin/env python3
|
||||
"""Standalone evidence package verifier."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def main():
|
||||
here = Path(__file__).parent
|
||||
manifest = json.loads((here / "manifest.json").read_text())
|
||||
|
||||
print("SooSeF Evidence Package Verifier")
|
||||
print("=" * 40)
|
||||
print(f"Exported: {manifest['exported_at']}")
|
||||
print(f"Investigation: {manifest.get('investigation', 'N/A')}")
|
||||
print()
|
||||
|
||||
errors = 0
|
||||
|
||||
# Verify image hashes
|
||||
print("Verifying image hashes...")
|
||||
for img_name in manifest.get("images", []):
|
||||
img_path = here / "images" / img_name
|
||||
if not img_path.exists():
|
||||
print(f" MISSING: {img_name}")
|
||||
errors += 1
|
||||
continue
|
||||
actual_hash = hashlib.sha256(img_path.read_bytes()).hexdigest()
|
||||
# Find matching attestation record
|
||||
matched = False
|
||||
for rec in manifest.get("attestation_records", []):
|
||||
if rec["sha256"] == actual_hash:
|
||||
print(f" OK: {img_name} (attested {rec['timestamp']})")
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
print(f" UNATTESTED: {img_name} (hash: {actual_hash[:16]}...)")
|
||||
errors += 1
|
||||
|
||||
# Verify chain linkage
|
||||
chain = manifest.get("chain_records", [])
|
||||
if chain:
|
||||
print(f"\\nVerifying chain linkage ({len(chain)} records)...")
|
||||
prev_hash = None
|
||||
for rec in chain:
|
||||
if prev_hash is not None and rec.get("prev_hash") != prev_hash:
|
||||
print(f" BROKEN: record {rec['chain_index']} prev_hash mismatch")
|
||||
errors += 1
|
||||
# Simple hash for next check
|
||||
canonical = (
|
||||
rec["content_hash"] + rec["prev_hash"] +
|
||||
rec["signer_pubkey"] + str(rec["chain_index"]) +
|
||||
str(rec["claimed_ts"]) + rec["content_type"]
|
||||
)
|
||||
prev_hash = hashlib.sha256(canonical.encode()).hexdigest()
|
||||
print(f" Chain: {len(chain)} records verified")
|
||||
|
||||
print()
|
||||
if errors:
|
||||
print(f"FAILED: {errors} error(s)")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("PASSED: All evidence verified successfully.")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
|
||||
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("manifest.json", json.dumps(manifest, indent=2))
|
||||
zf.writestr("README.txt", readme)
|
||||
zf.writestr("verify.py", verify_script)
|
||||
if public_key_path and public_key_path.exists():
|
||||
zf.write(public_key_path, "public_key.pem")
|
||||
for img_path in image_paths:
|
||||
zf.write(img_path, f"images/{img_path.name}")
|
||||
|
||||
return output_path
|
||||
150
src/soosef/federation/anchors.py
Normal file
150
src/soosef/federation/anchors.py
Normal file
@ -0,0 +1,150 @@
|
||||
"""
|
||||
External timestamp anchoring for the attestation chain.
|
||||
|
||||
Provides two mechanisms to externally prove that the chain head existed
|
||||
before a given time:
|
||||
|
||||
1. RFC 3161 TSA (Timestamping Authority) — automated, requires network
|
||||
2. Manual anchors — export chain head hash for manual submission to any
|
||||
external witness (blockchain, newspaper, tweet, TSA via email)
|
||||
|
||||
A single anchor for the chain head implicitly timestamps every record
|
||||
that preceded it, because the chain is append-only with hash linkage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from soosef.paths import CHAIN_DIR
|
||||
|
||||
|
||||
def get_chain_head_anchor() -> dict:
|
||||
"""Get the current chain head hash and metadata for anchoring.
|
||||
|
||||
Returns a dict suitable for submission to any external timestamp service.
|
||||
"""
|
||||
from soosef.federation.chain import ChainStore
|
||||
|
||||
store = ChainStore(CHAIN_DIR)
|
||||
state = store.state()
|
||||
|
||||
if state is None:
|
||||
return {"error": "Chain is empty"}
|
||||
|
||||
return {
|
||||
"anchor_version": "1",
|
||||
"chain_id": state.chain_id.hex(),
|
||||
"head_index": state.head_index,
|
||||
"head_hash": state.head_hash.hex(),
|
||||
"record_count": state.record_count,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"digest": hashlib.sha256(
|
||||
f"{state.chain_id.hex()}:{state.head_hash.hex()}:{state.head_index}".encode()
|
||||
).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def submit_rfc3161(anchor: dict, tsa_url: str = "https://freetsa.org/tsr") -> dict:
|
||||
"""Submit a chain head hash to an RFC 3161 Timestamping Authority.
|
||||
|
||||
Args:
|
||||
anchor: Output from get_chain_head_anchor().
|
||||
tsa_url: URL of the TSA service.
|
||||
|
||||
Returns:
|
||||
Dict with the TSA response token (base64-encoded DER) and metadata.
|
||||
"""
|
||||
import base64
|
||||
import urllib.request
|
||||
|
||||
# Build the timestamp request (SHA-256 digest)
|
||||
digest_bytes = bytes.fromhex(anchor["digest"])
|
||||
|
||||
# RFC 3161 TimeStampReq structure (minimal DER encoding)
|
||||
# This is a simplified approach — for production, use pyasn1 or similar
|
||||
# We submit the hash directly and get back a signed timestamp token
|
||||
req_data = digest_bytes
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
tsa_url,
|
||||
data=req_data,
|
||||
headers={
|
||||
"Content-Type": "application/timestamp-query",
|
||||
},
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=30)
|
||||
token = resp.read()
|
||||
|
||||
return {
|
||||
"tsa_url": tsa_url,
|
||||
"submitted_at": datetime.now(UTC).isoformat(),
|
||||
"digest": anchor["digest"],
|
||||
"head_index": anchor["head_index"],
|
||||
"token_b64": base64.b64encode(token).decode(),
|
||||
"token_size": len(token),
|
||||
"success": True,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"tsa_url": tsa_url,
|
||||
"error": str(e),
|
||||
"success": False,
|
||||
}
|
||||
|
||||
|
||||
def save_anchor(anchor: dict, tsa_response: dict | None = None) -> Path:
|
||||
"""Save an anchor record to the anchors directory.
|
||||
|
||||
Returns the path to the saved anchor file.
|
||||
"""
|
||||
anchors_dir = CHAIN_DIR / "anchors"
|
||||
anchors_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
record = {
|
||||
"anchor": anchor,
|
||||
"tsa": tsa_response,
|
||||
"saved_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
filename = f"anchor_{anchor['head_index']:06d}_{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}.json"
|
||||
path = anchors_dir / filename
|
||||
path.write_text(json.dumps(record, indent=2))
|
||||
return path
|
||||
|
||||
|
||||
def load_anchors() -> list[dict]:
|
||||
"""Load all saved anchor records, newest first."""
|
||||
anchors_dir = CHAIN_DIR / "anchors"
|
||||
if not anchors_dir.exists():
|
||||
return []
|
||||
|
||||
anchors = []
|
||||
for path in sorted(anchors_dir.glob("anchor_*.json"), reverse=True):
|
||||
try:
|
||||
anchors.append(json.loads(path.read_text()))
|
||||
except Exception:
|
||||
continue
|
||||
return anchors
|
||||
|
||||
|
||||
def export_anchor_for_manual_submission(anchor: dict) -> str:
|
||||
"""Format an anchor for manual external submission.
|
||||
|
||||
Returns a compact string suitable for tweeting, emailing to a TSA,
|
||||
publishing in a newspaper classified, or submitting to a blockchain.
|
||||
"""
|
||||
return (
|
||||
f"SooSeF Chain Anchor\n"
|
||||
f"Chain: {anchor['chain_id'][:16]}...\n"
|
||||
f"Head: #{anchor['head_index']} ({anchor['record_count']} records)\n"
|
||||
f"Hash: {anchor['digest']}\n"
|
||||
f"Time: {anchor['timestamp']}\n"
|
||||
f"\n"
|
||||
f"This hash proves the entire chain existed before this timestamp.\n"
|
||||
f"Verify: soosef chain verify && soosef chain anchor check {anchor['digest']}"
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user