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()
|
caption = request.form.get("caption", "").strip()
|
||||||
location_name = request.form.get("location_name", "").strip()
|
location_name = request.form.get("location_name", "").strip()
|
||||||
investigation = request.form.get("investigation", "").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:
|
if caption:
|
||||||
metadata["caption"] = caption
|
metadata["caption"] = caption
|
||||||
if location_name:
|
if location_name:
|
||||||
metadata["location_name"] = location_name
|
metadata["location_name"] = location_name
|
||||||
if investigation:
|
if investigation:
|
||||||
metadata["investigation"] = 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"
|
auto_exif = request.form.get("auto_exif", "on") == "on"
|
||||||
strip_device = request.form.get("strip_device", "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 flask import Blueprint, Response, flash, redirect, render_template, request, url_for
|
||||||
|
|
||||||
from soosef.audit import log_action
|
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")
|
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"
|
_TOKEN_DIR = TEMP_DIR / "dropbox"
|
||||||
|
_DB_PATH = AUTH_DIR / "dropbox.db"
|
||||||
|
|
||||||
|
|
||||||
def _ensure_token_dir():
|
def _ensure_token_dir():
|
||||||
@ -37,6 +35,75 @@ def _ensure_token_dir():
|
|||||||
_TOKEN_DIR.chmod(0o700)
|
_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"])
|
@bp.route("/admin", methods=["GET", "POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def admin():
|
def admin():
|
||||||
@ -49,14 +116,13 @@ def admin():
|
|||||||
max_files = int(request.form.get("max_files", 10))
|
max_files = int(request.form.get("max_files", 10))
|
||||||
|
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
_tokens[token] = {
|
conn = _get_db()
|
||||||
"created_at": datetime.now(UTC).isoformat(),
|
conn.execute(
|
||||||
"expires_at": (datetime.now(UTC) + timedelta(hours=hours)).isoformat(),
|
"INSERT INTO tokens (token, label, created_at, expires_at, max_files, used) VALUES (?, ?, ?, ?, ?, 0)",
|
||||||
"max_files": max_files,
|
(token, label, datetime.now(UTC).isoformat(), (datetime.now(UTC) + timedelta(hours=hours)).isoformat(), max_files),
|
||||||
"label": label,
|
)
|
||||||
"used": 0,
|
conn.commit()
|
||||||
"receipts": [],
|
conn.close()
|
||||||
}
|
|
||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
actor=request.environ.get("REMOTE_USER", "admin"),
|
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")
|
flash(f"Drop box created. Share this URL with your source: {upload_url}", "success")
|
||||||
|
|
||||||
elif action == "revoke":
|
elif action == "revoke":
|
||||||
token = request.form.get("token", "")
|
tok = request.form.get("token", "")
|
||||||
if token in _tokens:
|
conn = _get_db()
|
||||||
del _tokens[token]
|
conn.execute("DELETE FROM receipts WHERE token = ?", (tok,))
|
||||||
flash("Token revoked.", "success")
|
conn.execute("DELETE FROM tokens WHERE token = ?", (tok,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
flash("Token revoked.", "success")
|
||||||
|
|
||||||
# Clean expired tokens
|
return render_template("dropbox/admin.html", tokens=_get_all_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)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_token(token: str) -> dict | None:
|
def _validate_token(token: str) -> dict | None:
|
||||||
"""Check if a token is valid. Returns token data or None."""
|
"""Check if a token is valid. Returns token data or None."""
|
||||||
if token not in _tokens:
|
data = _get_token(token)
|
||||||
return None
|
if data is None:
|
||||||
data = _tokens[token]
|
|
||||||
if datetime.fromisoformat(data["expires_at"]) < datetime.now(UTC):
|
|
||||||
del _tokens[token]
|
|
||||||
return None
|
return None
|
||||||
if data["used"] >= data["max_files"]:
|
if data["used"] >= data["max_files"]:
|
||||||
return None
|
return None
|
||||||
@ -175,8 +235,14 @@ def upload(token):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # Attestation is best-effort; don't fail the upload
|
pass # Attestation is best-effort; don't fail the upload
|
||||||
|
|
||||||
# Generate receipt code
|
# Receipt code derived from file hash via HMAC — the source can
|
||||||
receipt_code = secrets.token_hex(8)
|
# independently verify their receipt corresponds to specific content
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
receipt_code = hmac.new(
|
||||||
|
token.encode(), sha256.encode(), hashlib.sha256
|
||||||
|
).hexdigest()[:16]
|
||||||
|
|
||||||
receipts.append({
|
receipts.append({
|
||||||
"filename": f.filename,
|
"filename": f.filename,
|
||||||
"sha256": sha256,
|
"sha256": sha256,
|
||||||
@ -184,8 +250,16 @@ def upload(token):
|
|||||||
"received_at": datetime.now(UTC).isoformat(),
|
"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["used"] += 1
|
||||||
token_data["receipts"].append(receipt_code)
|
|
||||||
|
|
||||||
remaining = token_data["max_files"] - token_data["used"]
|
remaining = token_data["max_files"] - token_data["used"]
|
||||||
|
|
||||||
@ -200,21 +274,56 @@ def upload(token):
|
|||||||
|
|
||||||
return Response(receipt_text, content_type="text/plain")
|
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"]
|
remaining = token_data["max_files"] - token_data["used"]
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html><head><title>Secure Upload</title>
|
<html><head><title>Secure Upload</title>
|
||||||
<style>body{{font-family:sans-serif;max-width:600px;margin:40px auto;padding:20px}}
|
<style>
|
||||||
input[type=file]{{margin:10px 0}}button{{padding:10px 20px}}</style></head>
|
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>
|
<body>
|
||||||
<h2>Secure File Upload</h2>
|
<h2>Secure File Upload</h2>
|
||||||
<p>Select files to upload. You may upload up to {remaining} file(s).</p>
|
<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>
|
<p>Your files will be fingerprinted in your browser before upload. Save the
|
||||||
<form method="POST" enctype="multipart/form-data">
|
fingerprints — they prove exactly what you submitted.</p>
|
||||||
<input type="file" name="files" multiple accept="image/*,.pdf,.doc,.docx,.txt"><br>
|
<form method="POST" enctype="multipart/form-data" id="uploadForm">
|
||||||
<button type="submit">Upload</button>
|
<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>
|
</form>
|
||||||
<p style="color:#666;font-size:12px">This link will expire automatically. Do not bookmark it.</p>
|
<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>"""
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
@ -225,12 +334,20 @@ def verify_receipt():
|
|||||||
if not code:
|
if not code:
|
||||||
return Response("No receipt code provided.", status=400, content_type="text/plain")
|
return Response("No receipt code provided.", status=400, content_type="text/plain")
|
||||||
|
|
||||||
for token_data in _tokens.values():
|
conn = _get_db()
|
||||||
if code in token_data["receipts"]:
|
row = conn.execute(
|
||||||
return Response(
|
"SELECT filename, sha256, received_at FROM receipts WHERE receipt_code = ?", (code,)
|
||||||
f"Receipt {code} is VALID. Your submission was received.",
|
).fetchone()
|
||||||
content_type="text/plain",
|
conn.close()
|
||||||
)
|
|
||||||
|
if row:
|
||||||
|
return Response(
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
f"Receipt {code} was not found. It may have expired.",
|
f"Receipt {code} was not found. It may have expired.",
|
||||||
|
|||||||
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:
|
def _format_us_timestamp(us: int) -> str:
|
||||||
"""Format a Unix microsecond timestamp for display."""
|
"""Format a Unix microsecond timestamp for display."""
|
||||||
from datetime import UTC, datetime
|
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