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

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

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

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

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

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

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

108
src/soosef/audit.py Normal file
View File

@@ -0,0 +1,108 @@
"""
Structured audit log for administrative and security-critical actions.
Writes append-only JSON-lines to ~/.soosef/audit.jsonl. Each line is a
self-contained JSON object so the file can be tailed, grepped, or ingested
by any log aggregator without a parser.
Entry schema
------------
{
"timestamp": "2026-04-01T12:34:56.789012+00:00", # ISO-8601 UTC
"actor": "alice", # username or "cli" for CLI invocations
"action": "user.delete", # dotted hierarchical action name
"target": "user:3", # affected resource (id, fingerprint, path …)
"outcome": "success", # "success" | "failure"
"source": "web", # "web" | "cli"
"detail": "optional extra" # omitted when None
}
Actions used by SooSeF
----------------------
user.create Admin created a new user account
user.delete Admin deleted a user account
user.password_reset Admin issued a temporary password for a user
key.channel.generate New channel key generated
key.identity.generate New Ed25519 identity generated
killswitch.fire Emergency purge executed
The log is intentionally destroyed by the killswitch (AUDIT_LOG is under
BASE_DIR). That is correct for this threat model: if you're wiping the
device you want the log gone too.
"""
from __future__ import annotations
import json
import logging
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal
import soosef.paths as paths
logger = logging.getLogger(__name__)
# Serialisation lock — multiple Gunicorn workers write to the same file;
# the lock only protects within a single process, but append writes to a
# local filesystem are atomic at the OS level for small payloads, so the
# worst case across workers is interleaved bytes within a single line,
# which is extremely unlikely given the small line sizes here. A proper
# multi-process solution would use a logging socket handler; this is
# acceptable for the offline-first threat model.
_lock = threading.Lock()
Outcome = Literal["success", "failure"]
Source = Literal["web", "cli"]
def log_action(
actor: str,
action: str,
target: str,
outcome: Outcome,
source: Source,
detail: str | None = None,
) -> None:
"""
Append one audit entry to ~/.soosef/audit.jsonl.
This function never raises — a failed write is logged to stderr and
silently swallowed so that audit log failures do not block user-facing
operations.
Args:
actor: Username performing the action, or ``"cli"`` for CLI calls.
action: Dotted action name, e.g. ``"user.delete"``.
target: Affected resource identifier, e.g. ``"user:3"`` or a key
fingerprint prefix.
outcome: ``"success"`` or ``"failure"``.
source: ``"web"`` or ``"cli"``.
detail: Optional free-text annotation (avoid PII where possible).
"""
entry: dict[str, str] = {
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
"actor": actor,
"action": action,
"target": target,
"outcome": outcome,
"source": source,
}
if detail is not None:
entry["detail"] = detail
line = json.dumps(entry, ensure_ascii=False) + "\n"
try:
log_path: Path = paths.AUDIT_LOG
# Ensure parent directory exists (BASE_DIR should already exist after
# ensure_dirs(), but be defensive in case audit is called early).
log_path.parent.mkdir(parents=True, exist_ok=True)
with _lock:
with log_path.open("a", encoding="utf-8") as fh:
fh.write(line)
except OSError as exc:
# Never crash a user-facing request because audit logging failed.
logger.error("audit: failed to write entry — %s", exc)

View File

@@ -7,10 +7,16 @@ plus native SooSeF commands for init, fieldkit, keys, and serve.
from __future__ import annotations
import logging
import threading
import time
from ipaddress import IPv4Address
from pathlib import Path
import click
logger = logging.getLogger(__name__)
@click.group()
@click.option(
@@ -105,10 +111,66 @@ def serve(host, port, no_https, debug):
_generate_self_signed_cert(SSL_CERT, SSL_KEY)
ssl_context = (str(SSL_CERT), str(SSL_KEY))
# Start the dead man's switch enforcement background thread.
# The thread checks every 60 seconds and fires the killswitch if overdue.
# It is a daemon thread — it dies automatically when the Flask process exits.
# We always start it; the loop itself only acts when the switch is armed,
# so it is safe to run even when the switch has never been configured.
_start_deadman_thread(interval_seconds=60)
click.echo(f"Starting SooSeF on {'https' if ssl_context else 'http'}://{host}:{port}")
app.run(host=host, port=port, debug=debug, ssl_context=ssl_context)
def _deadman_enforcement_loop(interval_seconds: int = 60) -> None:
"""
Background enforcement loop for the dead man's switch.
Runs in a daemon thread started by ``serve``. Calls ``DeadmanSwitch.check()``
every *interval_seconds*. If the switch fires, ``check()`` calls
``execute_purge`` internally and the process will lose its key material;
the thread then exits because there is nothing left to guard.
The loop re-evaluates ``is_armed()`` on every tick so it activates
automatically if the switch is armed after the server starts.
"""
from soosef.fieldkit.deadman import DeadmanSwitch
dm = DeadmanSwitch()
logger.debug("Dead man's switch enforcement loop started (interval=%ds)", interval_seconds)
while True:
time.sleep(interval_seconds)
try:
if dm.is_armed():
fired = dm.should_fire()
dm.check()
if fired:
# Killswitch has been triggered; no point continuing.
logger.warning("Dead man's switch fired — enforcement loop exiting")
return
except Exception:
logger.exception("Dead man's switch enforcement loop encountered an error")
def _start_deadman_thread(interval_seconds: int = 60) -> threading.Thread | None:
"""
Start the dead man's switch enforcement daemon thread.
Returns the thread object, or None if the thread could not be started.
The thread is a daemon so it will not block process exit.
"""
t = threading.Thread(
target=_deadman_enforcement_loop,
args=(interval_seconds,),
name="deadman-enforcement",
daemon=True,
)
t.start()
logger.info("Dead man's switch enforcement thread started (interval=%ds)", interval_seconds)
return t
def _generate_self_signed_cert(cert_path: Path, key_path: Path) -> None:
"""Generate a self-signed certificate for development/local use."""
from cryptography import x509
@@ -118,9 +180,11 @@ def _generate_self_signed_cert(cert_path: Path, key_path: Path) -> None:
from datetime import datetime, timedelta, UTC
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, "SooSeF Local"),
])
subject = issuer = x509.Name(
[
x509.NameAttribute(NameOID.COMMON_NAME, "SooSeF Local"),
]
)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
@@ -130,16 +194,22 @@ def _generate_self_signed_cert(cert_path: Path, key_path: Path) -> None:
.not_valid_before(datetime.now(UTC))
.not_valid_after(datetime.now(UTC) + timedelta(days=365))
.add_extension(
x509.SubjectAlternativeName([
x509.DNSName("localhost"),
x509.IPAddress(b"\x7f\x00\x00\x01".__class__(0x7F000001)),
]),
x509.SubjectAlternativeName(
[
x509.DNSName("localhost"),
x509.IPAddress(IPv4Address("127.0.0.1")),
]
),
critical=False,
)
.sign(key, hashes.SHA256())
)
key_path.write_bytes(
key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
)
key_path.chmod(0o600)
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
@@ -161,6 +231,7 @@ try:
for name, cmd in stegasoo_cli.commands.items():
stego.add_command(cmd, name)
except ImportError:
@stego.command()
def unavailable():
"""Stegasoo is not installed."""
@@ -182,12 +253,189 @@ try:
for name, cmd in verisoo_cli.commands.items():
attest.add_command(cmd, name)
except ImportError:
@attest.command()
def unavailable():
"""Verisoo is not installed."""
click.echo("Error: verisoo package not found. Install with: pip install verisoo")
def _attest_file(
file_path: Path,
private_key,
storage,
caption: str | None,
auto_exif: bool = True,
) -> None:
"""Attest a single file and store the result.
Shared by ``attest batch``. Raises on failure so the caller can decide
whether to abort or continue.
Args:
file_path: Path to the image file to attest.
private_key: Ed25519 private key loaded via verisoo.crypto.
storage: verisoo LocalStorage instance.
caption: Optional caption to embed in metadata.
auto_exif: Whether to extract EXIF metadata from the image.
"""
import hashlib
from cryptography.hazmat.primitives.serialization import (
Encoding,
PublicFormat,
load_pem_private_key,
)
from verisoo.attestation import create_attestation
from verisoo.models import Identity
from soosef.config import SoosefConfig
from soosef.federation.chain import ChainStore
from soosef.paths import CHAIN_DIR, IDENTITY_PRIVATE_KEY
image_data = file_path.read_bytes()
metadata: dict = {}
if caption:
metadata["caption"] = caption
attestation = create_attestation(
image_data=image_data,
private_key=private_key,
metadata=metadata if metadata else None,
auto_exif=auto_exif,
)
storage.append_record(attestation.record)
# Persist the local identity so verification can resolve the attestor name.
pub_bytes = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
identity = Identity(
public_key=pub_bytes,
fingerprint=attestation.record.attestor_fingerprint,
metadata={"name": "SooSeF Local Identity"},
)
try:
storage.save_identity(identity)
except Exception:
pass # Already exists — safe to ignore.
# Wrap in the hash chain if enabled.
config = SoosefConfig.load()
if config.chain_enabled and config.chain_auto_wrap and IDENTITY_PRIVATE_KEY.exists():
record_bytes = (
attestation.record.to_bytes()
if hasattr(attestation.record, "to_bytes")
else str(attestation.record).encode()
)
content_hash = hashlib.sha256(record_bytes).digest()
priv_pem = IDENTITY_PRIVATE_KEY.read_bytes()
chain_key = load_pem_private_key(priv_pem, password=None)
chain_metadata: dict = {}
if caption:
chain_metadata["caption"] = caption
ChainStore(CHAIN_DIR).append(
content_hash=content_hash,
content_type="verisoo/attestation-v1",
private_key=chain_key,
metadata=chain_metadata,
)
# ── Default extensions for batch attestation ──────────────────────────────────
_DEFAULT_EXTENSIONS: tuple[str, ...] = ("jpg", "jpeg", "png", "tiff", "tif", "webp")
@attest.command("batch")
@click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path))
@click.option("--caption", default=None, help="Shared caption to embed in every attestation.")
@click.option(
"--extensions",
default=",".join(_DEFAULT_EXTENSIONS),
show_default=True,
help="Comma-separated list of file extensions to include (without leading dot).",
)
@click.option(
"--no-exif",
is_flag=True,
help="Disable automatic EXIF extraction.",
)
def batch(directory: Path, caption: str | None, extensions: str, no_exif: bool) -> None:
"""Attest all matching images in DIRECTORY.
Iterates over every file whose extension matches --extensions, attests
each one, and prints a running progress line. Failures are noted and
reported in the final summary — the batch continues on individual errors.
Example:
soosef attest batch ./field-photos --caption "Kyiv, 2026-04-01"
soosef attest batch ./docs --extensions pdf,png --no-exif
"""
from verisoo.crypto import load_private_key
from verisoo.storage import LocalStorage
from soosef.paths import ATTESTATIONS_DIR, IDENTITY_PRIVATE_KEY
# Validate identity.
if not IDENTITY_PRIVATE_KEY.exists():
click.echo(
"Error: No identity configured. Run 'soosef init' first.",
err=True,
)
raise SystemExit(1)
private_key = load_private_key(IDENTITY_PRIVATE_KEY)
storage = LocalStorage(base_path=ATTESTATIONS_DIR)
auto_exif = not no_exif
# Collect matching files.
exts = {e.strip().lower().lstrip(".") for e in extensions.split(",") if e.strip()}
files: list[Path] = sorted(
f for f in directory.iterdir() if f.is_file() and f.suffix.lstrip(".").lower() in exts
)
if not files:
click.echo(
f"No matching files found in {directory} (extensions: {', '.join(sorted(exts))})"
)
return
total = len(files)
failures: list[tuple[str, str]] = []
for i, file_path in enumerate(files, start=1):
click.echo(f"Attesting {i}/{total}: {file_path.name} ... ", nl=False)
try:
_attest_file(
file_path=file_path,
private_key=private_key,
storage=storage,
caption=caption,
auto_exif=auto_exif,
)
click.echo("done")
except Exception as exc:
click.echo("FAILED")
logger.debug("Attestation failed for %s: %s", file_path.name, exc, exc_info=True)
failures.append((file_path.name, str(exc)))
# Summary.
succeeded = total - len(failures)
click.echo()
click.echo(f"{succeeded} file(s) attested, {len(failures)} failure(s).")
if failures:
click.echo("Failures:", err=True)
for name, reason in failures:
click.echo(f" {name}: {reason}", err=True)
raise SystemExit(1)
# ── Fieldkit sub-commands ───────────────────────────────────────────
@@ -207,12 +455,18 @@ def status():
ks_status = ks.status()
click.echo("=== SooSeF Fieldkit Status ===")
click.echo(f"Identity: {'Active (' + ks_status.identity_fingerprint[:16] + '...)' if ks_status.has_identity else 'None'}")
click.echo(f"Channel Key: {'Active (' + ks_status.channel_fingerprint[:16] + '...)' if ks_status.has_channel_key else 'None'}")
click.echo(
f"Identity: {'Active (' + ks_status.identity_fingerprint[:16] + '...)' if ks_status.has_identity else 'None'}"
)
click.echo(
f"Channel Key: {'Active (' + ks_status.channel_fingerprint[:16] + '...)' if ks_status.has_channel_key else 'None'}"
)
dm = DeadmanSwitch()
dm_status = dm.status()
click.echo(f"Dead Man: {'Armed (overdue!)' if dm_status['overdue'] else 'Armed' if dm_status['armed'] else 'Disarmed'}")
click.echo(
f"Dead Man: {'Armed (overdue!)' if dm_status['overdue'] else 'Armed' if dm_status['armed'] else 'Disarmed'}"
)
@fieldkit.command()
@@ -244,6 +498,220 @@ def checkin():
click.echo("Check-in recorded.")
@fieldkit.command("check-deadman")
def check_deadman():
"""Run the dead man's switch check — fires killswitch if overdue.
Safe to call from cron or systemd. Exits with status 0 if the switch
is disarmed or not yet overdue. Exits with status 2 if the switch fired
and the killswitch was triggered (so cron/systemd can alert on it).
Exits with status 1 on unexpected errors.
"""
from soosef.fieldkit.deadman import DeadmanSwitch
dm = DeadmanSwitch()
if not dm.is_armed():
click.echo("Dead man's switch is not armed — nothing to do.")
return
fired = dm.should_fire()
try:
dm.check()
except Exception as exc:
click.echo(f"Error running dead man's check: {exc}", err=True)
raise SystemExit(1)
if fired:
click.echo(
"DEAD MAN'S SWITCH EXPIRED — killswitch triggered.",
err=True,
)
raise SystemExit(2)
s = dm.status()
if s["overdue"]:
click.echo(
f"Dead man's switch is OVERDUE (last check-in: {s['last_checkin']}) "
f"— grace period in effect, will fire soon.",
err=True,
)
else:
click.echo(f"Dead man's switch OK. Next due: {s.get('next_due', 'unknown')}")
# ── Fieldkit: geofence sub-commands ─────────────────────────────
@fieldkit.group()
def geofence():
"""Geofence configuration and checks."""
pass
@geofence.command("set")
@click.option("--lat", required=True, type=float, help="Fence center latitude")
@click.option("--lon", required=True, type=float, help="Fence center longitude")
@click.option("--radius", required=True, type=float, help="Fence radius in meters")
@click.option("--name", default="default", show_default=True, help="Human-readable fence name")
def geofence_set(lat, lon, radius, name):
"""Set the geofence — saves center and radius to ~/.soosef/fieldkit/geofence.json."""
from soosef.fieldkit.geofence import GeoCircle, save_fence
if radius <= 0:
click.echo("Error: --radius must be a positive number of meters.", err=True)
raise SystemExit(1)
if not (-90.0 <= lat <= 90.0):
click.echo("Error: --lat must be between -90 and 90.", err=True)
raise SystemExit(1)
if not (-180.0 <= lon <= 180.0):
click.echo("Error: --lon must be between -180 and 180.", err=True)
raise SystemExit(1)
fence = GeoCircle(lat=lat, lon=lon, radius_m=radius, name=name)
save_fence(fence)
click.echo(f"Geofence '{name}' set: center ({lat}, {lon}), radius {radius} m")
@geofence.command("check")
@click.option("--lat", required=True, type=float, help="Current latitude to check")
@click.option("--lon", required=True, type=float, help="Current longitude to check")
def geofence_check(lat, lon):
"""Check whether a point is inside the configured geofence.
Exit codes: 0 = inside fence, 1 = outside fence, 2 = no fence configured.
"""
from soosef.fieldkit.geofence import haversine_distance, is_inside, load_fence
fence = load_fence()
if fence is None:
click.echo("No geofence configured. Run 'soosef fieldkit geofence set' first.", err=True)
raise SystemExit(2)
inside = is_inside(fence, lat, lon)
distance = haversine_distance(fence.lat, fence.lon, lat, lon)
status = "INSIDE" if inside else "OUTSIDE"
click.echo(
f"{status} fence '{fence.name}' "
f"(distance: {distance:.1f} m, radius: {fence.radius_m} m)"
)
raise SystemExit(0 if inside else 1)
@geofence.command("clear")
def geofence_clear():
"""Remove the geofence configuration."""
from soosef.fieldkit.geofence import clear_fence
removed = clear_fence()
if removed:
click.echo("Geofence cleared.")
else:
click.echo("No geofence was configured.")
# ── Fieldkit: USB sub-commands ────────────────────────────────────
@fieldkit.group()
def usb():
"""USB device whitelist management."""
pass
def _enumerate_usb_devices() -> list[dict[str, str]]:
"""Return a list of currently connected USB devices.
Each dict has keys: device_id (vid:pid), vendor, model.
Requires pyudev (Linux only).
"""
try:
import pyudev
except ImportError:
raise RuntimeError("pyudev not available — USB commands require Linux + pyudev")
context = pyudev.Context()
devices = []
seen: set[str] = set()
for device in context.list_devices(subsystem="usb"):
vid = device.get("ID_VENDOR_ID", "")
pid = device.get("ID_MODEL_ID", "")
if not vid or not pid:
continue
device_id = f"{vid}:{pid}"
if device_id in seen:
continue
seen.add(device_id)
devices.append(
{
"device_id": device_id,
"vendor": device.get("ID_VENDOR", "unknown"),
"model": device.get("ID_MODEL", "unknown"),
}
)
return devices
@usb.command("snapshot")
def usb_snapshot():
"""Save currently connected USB devices as the whitelist.
Overwrites ~/.soosef/fieldkit/usb/whitelist.json with all USB devices
currently visible on the system. Run this once on a known-good machine.
"""
from soosef.fieldkit.usb_monitor import save_whitelist
try:
devices = _enumerate_usb_devices()
except RuntimeError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
device_ids = {d["device_id"] for d in devices}
save_whitelist(device_ids)
click.echo(f"Saved {len(device_ids)} device(s) to USB whitelist:")
for d in sorted(devices, key=lambda x: x["device_id"]):
click.echo(f" {d['device_id']} {d['vendor']} {d['model']}")
@usb.command("check")
def usb_check():
"""Compare connected USB devices against the whitelist.
Exit codes: 0 = all devices known, 1 = unknown device(s) detected,
2 = no whitelist configured (run 'soosef fieldkit usb snapshot' first).
"""
from soosef.fieldkit.usb_monitor import load_whitelist
whitelist = load_whitelist()
if not whitelist:
from soosef.paths import USB_WHITELIST
if not USB_WHITELIST.exists():
click.echo(
"No USB whitelist found. Run 'soosef fieldkit usb snapshot' first.", err=True
)
raise SystemExit(2)
try:
devices = _enumerate_usb_devices()
except RuntimeError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
unknown = [d for d in devices if d["device_id"] not in whitelist]
if not unknown:
click.echo(f"All {len(devices)} connected device(s) are whitelisted.")
raise SystemExit(0)
click.echo(f"WARNING: {len(unknown)} unknown device(s) detected:", err=True)
for d in unknown:
click.echo(f" {d['device_id']} {d['vendor']} {d['model']}", err=True)
raise SystemExit(1)
# ── Keys sub-commands ───────────────────────────────────────────────
@@ -286,3 +754,316 @@ def import_keys(bundle, password):
imported = import_bundle(bundle, IDENTITY_DIR, CHANNEL_KEY_FILE, password.encode())
click.echo(f"Imported: {', '.join(imported.keys())}")
@keys.command("rotate-identity")
@click.confirmation_option(
prompt="This will archive the current identity and generate a new keypair. Continue?"
)
def rotate_identity():
"""Rotate the Ed25519 identity keypair — archive old, generate new.
The current private and public key are preserved in a timestamped
archive directory under ~/.soosef/identity/archived/ so that
previously signed attestations can still be verified with the old key.
After rotation, notify all collaborators of the new fingerprint so
they can update their trusted-key lists.
"""
from soosef.exceptions import KeystoreError
from soosef.keystore.manager import KeystoreManager
ks = KeystoreManager()
try:
result = ks.rotate_identity()
except KeystoreError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
click.echo("Identity rotated successfully.")
click.echo(f" Old fingerprint: {result.old_fingerprint}")
click.echo(f" New fingerprint: {result.new_fingerprint}")
click.echo(f" Archive: {result.archive_path}")
click.echo()
click.echo(
"IMPORTANT: Notify all collaborators of your new fingerprint so they can "
"update their trusted-key lists. Attestations signed with the old key "
"remain verifiable using the archived public key."
)
@keys.command("rotate-channel")
@click.confirmation_option(
prompt="This will archive the current channel key and generate a new one. Continue?"
)
def rotate_channel():
"""Rotate the Stegasoo channel key — archive old, generate new.
The current channel key is preserved in a timestamped archive directory
under ~/.soosef/stegasoo/archived/ before the new key is generated.
After rotation, all parties sharing this channel must receive the new
key out-of-band before they can decode new messages.
"""
from soosef.exceptions import KeystoreError
from soosef.keystore.manager import KeystoreManager
ks = KeystoreManager()
try:
result = ks.rotate_channel_key()
except KeystoreError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
click.echo("Channel key rotated successfully.")
click.echo(f" Old fingerprint: {result.old_fingerprint}")
click.echo(f" New fingerprint: {result.new_fingerprint}")
click.echo(f" Archive: {result.archive_path}")
click.echo()
click.echo(
"IMPORTANT: Distribute the new channel key to all channel participants "
"out-of-band. Messages encoded with the old key cannot be decoded "
"with the new one."
)
# ── Chain sub-commands ─────────────────────────────────────────────
@main.group()
def chain():
"""Attestation hash chain operations."""
pass
@chain.command()
@click.pass_context
def status(ctx):
"""Show chain status — head index, chain ID, record count."""
from soosef.federation.chain import ChainStore
from soosef.paths import CHAIN_DIR
store = ChainStore(CHAIN_DIR)
state = store.state()
if state is None:
click.echo("Chain is empty — no records yet.")
click.echo("Attest an image or run 'soosef chain backfill' to populate.")
return
json_out = ctx.obj.get("json", False)
if json_out:
import json
click.echo(
json.dumps(
{
"chain_id": state.chain_id.hex(),
"head_index": state.head_index,
"head_hash": state.head_hash.hex(),
"record_count": state.record_count,
"created_at": state.created_at,
"last_append_at": state.last_append_at,
}
)
)
else:
click.echo("=== Attestation Chain ===")
click.echo(f"Chain ID: {state.chain_id.hex()[:32]}...")
click.echo(f"Records: {state.record_count}")
click.echo(f"Head index: {state.head_index}")
click.echo(f"Head hash: {state.head_hash.hex()[:32]}...")
click.echo(f"Created: {_format_us_timestamp(state.created_at)}")
click.echo(f"Last append: {_format_us_timestamp(state.last_append_at)}")
@chain.command()
def verify():
"""Verify chain integrity — check all hashes and signatures."""
from soosef.federation.chain import ChainStore
from soosef.paths import CHAIN_DIR
store = ChainStore(CHAIN_DIR)
state = store.state()
if state is None:
click.echo("Chain is empty — nothing to verify.")
return
click.echo(f"Verifying {state.record_count} records...")
try:
store.verify_chain()
click.echo("Chain integrity OK — all hashes and signatures valid.")
except Exception as e:
click.echo(f"INTEGRITY VIOLATION: {e}", err=True)
raise SystemExit(1)
@chain.command()
@click.argument("index", type=int)
@click.pass_context
def show(ctx, index):
"""Show a specific chain record by index."""
from soosef.exceptions import ChainError
from soosef.federation.chain import ChainStore
from soosef.federation.serialization import compute_record_hash
from soosef.paths import CHAIN_DIR
store = ChainStore(CHAIN_DIR)
try:
record = store.get(index)
except ChainError as e:
click.echo(f"Error: {e}", err=True)
raise SystemExit(1)
json_out = ctx.obj.get("json", False)
if json_out:
import json
click.echo(
json.dumps(
{
"version": record.version,
"record_id": record.record_id.hex(),
"chain_index": record.chain_index,
"prev_hash": record.prev_hash.hex(),
"content_hash": record.content_hash.hex(),
"content_type": record.content_type,
"metadata": record.metadata,
"claimed_ts": record.claimed_ts,
"signer_pubkey": record.signer_pubkey.hex(),
"record_hash": compute_record_hash(record).hex(),
}
)
)
else:
click.echo(f"=== Record #{record.chain_index} ===")
click.echo(f"Record ID: {record.record_id.hex()}")
click.echo(f"Record hash: {compute_record_hash(record).hex()[:32]}...")
click.echo(f"Prev hash: {record.prev_hash.hex()[:32]}...")
click.echo(f"Content hash: {record.content_hash.hex()[:32]}...")
click.echo(f"Content type: {record.content_type}")
click.echo(f"Timestamp: {_format_us_timestamp(record.claimed_ts)}")
click.echo(f"Signer: {record.signer_pubkey.hex()[:32]}...")
if record.metadata:
click.echo(f"Metadata: {record.metadata}")
if record.entropy_witnesses:
ew = record.entropy_witnesses
click.echo(
f"Entropy: uptime={ew.sys_uptime:.1f}s "
f"entropy_avail={ew.proc_entropy} "
f"boot_id={ew.boot_id[:16]}..."
)
@chain.command()
@click.option("-n", "--count", default=20, help="Number of records to show")
@click.pass_context
def log(ctx, count):
"""Show recent chain records (newest first)."""
from soosef.federation.chain import ChainStore
from soosef.federation.serialization import compute_record_hash
from soosef.paths import CHAIN_DIR
store = ChainStore(CHAIN_DIR)
state = store.state()
if state is None:
click.echo("Chain is empty.")
return
start = max(0, state.head_index - count + 1)
records = list(store.iter_records(start, state.head_index))
records.reverse() # newest first
click.echo(f"=== Last {len(records)} of {state.record_count} records ===")
click.echo()
for r in records:
ts = _format_us_timestamp(r.claimed_ts)
rhash = compute_record_hash(r).hex()[:16]
caption = r.metadata.get("caption", "")
label = f"{caption}" if caption else ""
click.echo(f" #{r.chain_index:>5} {ts} {rhash}... {r.content_type}{label}")
@chain.command()
@click.confirmation_option(prompt="Backfill existing Verisoo attestations into the chain?")
def backfill():
"""Import existing Verisoo attestations into the hash chain.
Reads all records from the Verisoo attestation log and wraps each one
in a chain record. Backfilled records are marked with metadata
backfilled=true and entropy witnesses reflect migration time.
"""
import hashlib
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from soosef.federation.chain import ChainStore
from soosef.paths import ATTESTATIONS_DIR, CHAIN_DIR, IDENTITY_PRIVATE_KEY
if not IDENTITY_PRIVATE_KEY.exists():
click.echo("Error: No identity found. Run 'soosef init' first.", err=True)
raise SystemExit(1)
priv_pem = IDENTITY_PRIVATE_KEY.read_bytes()
private_key = load_pem_private_key(priv_pem, password=None)
try:
from verisoo.storage import LocalStorage
storage = LocalStorage(base_path=ATTESTATIONS_DIR)
stats = storage.get_stats()
except Exception as e:
click.echo(f"Error reading Verisoo log: {e}", err=True)
raise SystemExit(1)
if stats.record_count == 0:
click.echo("No Verisoo attestations to backfill.")
return
store = ChainStore(CHAIN_DIR)
existing = store.state()
if existing and existing.record_count > 0:
click.echo(
f"Warning: chain already has {existing.record_count} records. "
f"Backfill will append after index {existing.head_index}."
)
count = 0
for i in range(stats.record_count):
try:
record = storage.get_record(i)
record_bytes = (
record.to_bytes() if hasattr(record, "to_bytes") else str(record).encode()
)
content_hash = hashlib.sha256(record_bytes).digest()
original_ts = int(record.timestamp.timestamp() * 1_000_000) if record.timestamp else 0
metadata = {
"backfilled": True,
"original_ts": original_ts,
"verisoo_index": i,
}
if hasattr(record, "attestor_fingerprint"):
metadata["attestor"] = record.attestor_fingerprint
store.append(
content_hash=content_hash,
content_type="verisoo/attestation-v1",
private_key=private_key,
metadata=metadata,
)
count += 1
except Exception as e:
click.echo(f" Warning: skipped record {i}: {e}")
click.echo(f"Backfilled {count} attestation(s) into the chain.")
def _format_us_timestamp(us: int) -> str:
"""Format a Unix microsecond timestamp for display."""
from datetime import UTC, datetime
dt = datetime.fromtimestamp(us / 1_000_000, tz=UTC)
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")

View File

@@ -34,6 +34,10 @@ class SoosefConfig:
usb_monitoring_enabled: bool = False
tamper_monitoring_enabled: bool = False
# Attestation chain
chain_enabled: bool = True
chain_auto_wrap: bool = True # Auto-wrap verisoo attestations in chain records
# Hardware (RPi)
gpio_killswitch_pin: int = 17
gpio_killswitch_hold_seconds: float = 5.0

View File

@@ -28,3 +28,15 @@ class KillswitchError(FieldkitError):
class InitError(SoosefError):
"""Initialization/setup error."""
class ChainError(SoosefError):
"""Hash chain error."""
class ChainIntegrityError(ChainError):
"""Chain integrity violation — tampered or corrupted records."""
class ChainAppendError(ChainError):
"""Failed to append to chain."""

View File

@@ -0,0 +1,17 @@
"""
Federated attestation system for SooSeF.
Provides hash-chained attestation records with tamper-evident ordering,
encrypted export bundles, and a Certificate Transparency-inspired
federated append-only log for distributing attestations across an air gap.
"""
from soosef.federation.chain import ChainStore
from soosef.federation.models import AttestationChainRecord, ChainState, EntropyWitnesses
__all__ = [
"AttestationChainRecord",
"ChainState",
"ChainStore",
"EntropyWitnesses",
]

View File

@@ -0,0 +1,465 @@
"""
Append-only hash chain store for attestation records.
Storage format:
- chain.bin: length-prefixed CBOR records (uint32 BE + serialized record)
- state.cbor: chain state checkpoint (performance optimization)
The canonical state is always derivable from chain.bin. If state.cbor is
corrupted or missing, it is rebuilt by scanning the log.
"""
from __future__ import annotations
import fcntl
import hashlib
import os
import struct
import time
from collections.abc import Iterator
from pathlib import Path
import cbor2
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from soosef.exceptions import ChainAppendError, ChainError, ChainIntegrityError
from soosef.federation.entropy import collect_entropy_witnesses
from soosef.federation.models import AttestationChainRecord, ChainState
from soosef.federation.serialization import (
canonical_bytes,
compute_record_hash,
deserialize_record,
serialize_record,
)
# Length prefix: 4 bytes, big-endian unsigned 32-bit
_LEN_STRUCT = struct.Struct(">I")
# Maximum record size: 1 MiB. Far larger than any valid record (~200-500 bytes
# typically). Prevents OOM from corrupted length prefixes in chain.bin.
MAX_RECORD_SIZE = 1_048_576
def _now_us() -> int:
"""Current time as Unix microseconds."""
return int(time.time() * 1_000_000)
class ChainStore:
"""Manages an append-only hash chain of attestation records.
Thread safety: single-writer via fcntl.flock. Multiple readers are safe.
Offset index: ``_offsets`` maps chain_index (int) to the byte offset of
that record's length prefix in chain.bin. It is built lazily during
``_rebuild_state()`` and kept up-to-date by ``append()``. The index is
in-memory only — it is reconstructed on every cold load, which is fast
because it is done in the same single pass that already must read every
record to compute the chain state.
"""
def __init__(self, chain_dir: Path):
self._dir = chain_dir
self._chain_file = chain_dir / "chain.bin"
self._state_file = chain_dir / "state.cbor"
self._dir.mkdir(parents=True, exist_ok=True)
self._state: ChainState | None = None
# chain_index → byte offset of the record's 4-byte length prefix.
# None means the index has not been built yet (cold start).
self._offsets: dict[int, int] | None = None
def _load_state(self) -> ChainState | None:
"""Load cached state from state.cbor."""
if self._state is not None:
return self._state
if self._state_file.exists():
data = self._state_file.read_bytes()
m = cbor2.loads(data)
self._state = ChainState(
chain_id=m["chain_id"],
head_index=m["head_index"],
head_hash=m["head_hash"],
record_count=m["record_count"],
created_at=m["created_at"],
last_append_at=m["last_append_at"],
)
return self._state
# No state file — rebuild if chain.bin exists
if self._chain_file.exists() and self._chain_file.stat().st_size > 0:
return self._rebuild_state()
return None
def _save_state(self, state: ChainState) -> None:
"""Atomically write state checkpoint."""
m = {
"chain_id": state.chain_id,
"head_index": state.head_index,
"head_hash": state.head_hash,
"record_count": state.record_count,
"created_at": state.created_at,
"last_append_at": state.last_append_at,
}
tmp = self._state_file.with_suffix(".tmp")
tmp.write_bytes(cbor2.dumps(m, canonical=True))
tmp.rename(self._state_file)
self._state = state
def _rebuild_state(self) -> ChainState:
"""Rebuild state by scanning chain.bin. Used on corruption or first load.
Also builds the in-memory offset index in the same pass so that no
second scan is ever needed.
"""
genesis = None
last = None
count = 0
offsets: dict[int, int] = {}
for offset, record in self._iter_raw_with_offsets():
offsets[record.chain_index] = offset
if count == 0:
genesis = record
last = record
count += 1
if genesis is None or last is None:
raise ChainError("Chain file exists but contains no valid records.")
self._offsets = offsets
state = ChainState(
chain_id=hashlib.sha256(canonical_bytes(genesis)).digest(),
head_index=last.chain_index,
head_hash=compute_record_hash(last),
record_count=count,
created_at=genesis.claimed_ts,
last_append_at=last.claimed_ts,
)
self._save_state(state)
return state
def _iter_raw_with_offsets(self) -> Iterator[tuple[int, AttestationChainRecord]]:
"""Iterate all records, yielding (byte_offset, record) pairs.
``byte_offset`` is the position of the record's 4-byte length prefix
within chain.bin. Used internally to build and exploit the offset index.
"""
if not self._chain_file.exists():
return
with open(self._chain_file, "rb") as f:
while True:
offset = f.tell()
len_bytes = f.read(4)
if len(len_bytes) < 4:
break
(record_len,) = _LEN_STRUCT.unpack(len_bytes)
if record_len > MAX_RECORD_SIZE:
raise ChainError(
f"Record length {record_len} exceeds maximum {MAX_RECORD_SIZE}"
f"chain file may be corrupted"
)
record_bytes = f.read(record_len)
if len(record_bytes) < record_len:
break
yield offset, deserialize_record(record_bytes)
def _iter_raw(self) -> Iterator[AttestationChainRecord]:
"""Iterate all records from chain.bin without state checks."""
for _offset, record in self._iter_raw_with_offsets():
yield record
def _ensure_offsets(self) -> dict[int, int]:
"""Return the offset index, building it if necessary."""
if self._offsets is None:
# Trigger a full scan; _rebuild_state populates self._offsets.
if self._chain_file.exists() and self._chain_file.stat().st_size > 0:
self._rebuild_state()
else:
self._offsets = {}
return self._offsets # type: ignore[return-value]
def _read_record_at(self, offset: int) -> AttestationChainRecord:
"""Read and deserialize the single record whose length prefix is at *offset*."""
with open(self._chain_file, "rb") as f:
f.seek(offset)
len_bytes = f.read(4)
if len(len_bytes) < 4:
raise ChainError(f"Truncated length prefix at offset {offset}.")
(record_len,) = _LEN_STRUCT.unpack(len_bytes)
if record_len > MAX_RECORD_SIZE:
raise ChainError(
f"Record length {record_len} exceeds maximum {MAX_RECORD_SIZE}"
f"chain file may be corrupted"
)
record_bytes = f.read(record_len)
if len(record_bytes) < record_len:
raise ChainError(f"Truncated record body at offset {offset}.")
return deserialize_record(record_bytes)
def state(self) -> ChainState | None:
"""Get current chain state, or None if chain is empty."""
return self._load_state()
def is_empty(self) -> bool:
"""True if the chain has no records."""
return self._load_state() is None
def head(self) -> AttestationChainRecord | None:
"""Return the most recent record, or None if chain is empty."""
state = self._load_state()
if state is None:
return None
return self.get(state.head_index)
def get(self, index: int) -> AttestationChainRecord:
"""Get a record by chain index. O(1) via offset index. Raises ChainError if not found."""
offsets = self._ensure_offsets()
if index not in offsets:
raise ChainError(f"Record at index {index} not found.")
return self._read_record_at(offsets[index])
def iter_records(
self, start: int = 0, end: int | None = None
) -> Iterator[AttestationChainRecord]:
"""Iterate records in [start, end] range (inclusive).
Seeks directly to the first record in range via the offset index, so
records before *start* are never read or deserialized.
"""
offsets = self._ensure_offsets()
if not offsets:
return
# Determine the byte offset to start reading from.
if start in offsets:
seek_offset = offsets[start]
elif start == 0:
seek_offset = 0
else:
# start index not in chain — find the nearest offset above start.
candidates = [off for idx, off in offsets.items() if idx >= start]
if not candidates:
return
seek_offset = min(candidates)
with open(self._chain_file, "rb") as f:
f.seek(seek_offset)
while True:
len_bytes = f.read(4)
if len(len_bytes) < 4:
break
(record_len,) = _LEN_STRUCT.unpack(len_bytes)
if record_len > MAX_RECORD_SIZE:
raise ChainError(
f"Record length {record_len} exceeds maximum {MAX_RECORD_SIZE}"
f"chain file may be corrupted"
)
record_bytes = f.read(record_len)
if len(record_bytes) < record_len:
break
record = deserialize_record(record_bytes)
if end is not None and record.chain_index > end:
break
yield record
def append(
self,
content_hash: bytes,
content_type: str,
private_key: Ed25519PrivateKey,
metadata: dict | None = None,
) -> AttestationChainRecord:
"""Create, sign, and append a new record to the chain.
The entire read-compute-write cycle runs under an exclusive file lock
to prevent concurrent writers from forking the chain (TOCTOU defense).
Args:
content_hash: SHA-256 of the content being attested.
content_type: MIME-like type identifier for the content.
private_key: Ed25519 private key for signing.
metadata: Optional extensible key-value metadata.
Returns:
The newly created and appended AttestationChainRecord.
"""
from uuid_utils import uuid7
# Pre-compute values that don't depend on chain state
public_key = private_key.public_key()
pub_bytes = public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
try:
with open(self._chain_file, "ab") as f:
fcntl.flock(f, fcntl.LOCK_EX)
try:
# Re-read state INSIDE the lock to prevent TOCTOU races.
# Also invalidate the offset index so that any records
# written by another process since our last read are picked
# up during the ensuing offset rebuild.
self._state = None
self._offsets = None
state = self._load_state()
# Ensure the offset index reflects the current file contents
# (including any records appended by other processes). This
# is a full scan only when state.cbor exists and the index
# was not already built by _rebuild_state() above.
self._ensure_offsets()
now = _now_us()
if state is None:
chain_index = 0
prev_hash = ChainState.GENESIS_PREV_HASH
else:
chain_index = state.head_index + 1
prev_hash = state.head_hash
entropy = collect_entropy_witnesses(self._chain_file)
# Build unsigned record
record = AttestationChainRecord(
version=1,
record_id=uuid7().bytes,
chain_index=chain_index,
prev_hash=prev_hash,
content_hash=content_hash,
content_type=content_type,
metadata=metadata or {},
claimed_ts=now,
entropy_witnesses=entropy,
signer_pubkey=pub_bytes,
signature=b"", # placeholder
)
# Sign canonical bytes
sig = private_key.sign(canonical_bytes(record))
# Replace with signed record (frozen dataclass)
record = AttestationChainRecord(
version=record.version,
record_id=record.record_id,
chain_index=record.chain_index,
prev_hash=record.prev_hash,
content_hash=record.content_hash,
content_type=record.content_type,
metadata=record.metadata,
claimed_ts=record.claimed_ts,
entropy_witnesses=record.entropy_witnesses,
signer_pubkey=record.signer_pubkey,
signature=sig,
)
# Serialize and write
record_bytes = serialize_record(record)
length_prefix = _LEN_STRUCT.pack(len(record_bytes))
# Record the byte offset before writing so it can be added
# to the in-memory offset index without a second file scan.
new_record_offset = f.seek(0, os.SEEK_CUR)
f.write(length_prefix)
f.write(record_bytes)
f.flush()
os.fsync(f.fileno())
# Update state inside the lock
record_hash = compute_record_hash(record)
if state is None:
chain_id = hashlib.sha256(canonical_bytes(record)).digest()
new_state = ChainState(
chain_id=chain_id,
head_index=0,
head_hash=record_hash,
record_count=1,
created_at=now,
last_append_at=now,
)
else:
new_state = ChainState(
chain_id=state.chain_id,
head_index=chain_index,
head_hash=record_hash,
record_count=state.record_count + 1,
created_at=state.created_at,
last_append_at=now,
)
self._save_state(new_state)
# Keep the offset index consistent so subsequent get() /
# iter_records() calls on this instance remain O(1).
if self._offsets is not None:
self._offsets[chain_index] = new_record_offset
finally:
fcntl.flock(f, fcntl.LOCK_UN)
except OSError as e:
raise ChainAppendError(f"Failed to write to chain: {e}") from e
return record
def verify_chain(self, start: int = 0, end: int | None = None) -> bool:
"""Verify hash chain integrity and signatures over a range.
Args:
start: First record index to verify (default 0).
end: Last record index to verify (default: head).
Returns:
True if the chain is valid.
Raises:
ChainIntegrityError: If any integrity check fails.
"""
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
prev_record: AttestationChainRecord | None = None
expected_index = start
# If starting from 0, first record must have genesis prev_hash
if start > 0:
# Load the record before start to check the first prev_hash
try:
prev_record = self.get(start - 1)
except ChainError:
pass # Can't verify prev_hash of first record in range
signer_pubkey: bytes | None = None
for record in self.iter_records(start, end):
# Check index continuity
if record.chain_index != expected_index:
raise ChainIntegrityError(
f"Expected index {expected_index}, got {record.chain_index}"
)
# Check prev_hash linkage
if prev_record is not None:
expected_hash = compute_record_hash(prev_record)
if record.prev_hash != expected_hash:
raise ChainIntegrityError(
f"Record {record.chain_index}: prev_hash mismatch"
)
elif record.chain_index == 0:
if record.prev_hash != ChainState.GENESIS_PREV_HASH:
raise ChainIntegrityError("Genesis record has non-zero prev_hash")
# Check signature
try:
pub = Ed25519PublicKey.from_public_bytes(record.signer_pubkey)
pub.verify(record.signature, canonical_bytes(record))
except Exception as e:
raise ChainIntegrityError(
f"Record {record.chain_index}: signature verification failed: {e}"
) from e
# Check single-signer invariant
if signer_pubkey is None:
signer_pubkey = record.signer_pubkey
elif record.signer_pubkey != signer_pubkey:
raise ChainIntegrityError(
f"Record {record.chain_index}: signer changed "
f"(expected {signer_pubkey.hex()[:16]}..., "
f"got {record.signer_pubkey.hex()[:16]}...)"
)
prev_record = record
expected_index += 1
return True

View File

@@ -0,0 +1,81 @@
"""System entropy collection for timestamp plausibility witnesses."""
from __future__ import annotations
import hashlib
import os
import time
import uuid
from pathlib import Path
from soosef.federation.models import EntropyWitnesses
# Cache boot_id for the lifetime of the process (fallback only)
_cached_boot_id: str | None = None
def _read_proc_file(path: str) -> str | None:
"""Read a /proc file, returning None if unavailable."""
try:
return Path(path).read_text().strip()
except OSError:
return None
def _get_boot_id() -> str:
"""Get the kernel boot ID (Linux) or a per-process fallback."""
global _cached_boot_id
boot_id = _read_proc_file("/proc/sys/kernel/random/boot_id")
if boot_id:
return boot_id
# Non-Linux fallback: stable per process lifetime
if _cached_boot_id is None:
_cached_boot_id = str(uuid.uuid4())
return _cached_boot_id
def _get_proc_entropy() -> int:
"""Get kernel entropy pool availability (Linux) or a fallback marker."""
value = _read_proc_file("/proc/sys/kernel/random/entropy_avail")
if value is not None:
try:
return int(value)
except ValueError:
pass
# Non-Linux fallback: always 32 (marker value)
return len(os.urandom(32))
def _get_fs_snapshot(path: Path) -> bytes:
"""Hash filesystem metadata of the given path, truncated to 16 bytes.
Includes mtime, ctime, size, and inode to capture any filesystem change.
"""
try:
st = path.stat()
data = f"{st.st_mtime_ns}:{st.st_ctime_ns}:{st.st_size}:{st.st_ino}".encode()
except OSError:
# Path doesn't exist yet (first record) — hash the parent dir
try:
st = path.parent.stat()
data = f"{st.st_mtime_ns}:{st.st_ctime_ns}:{st.st_size}:{st.st_ino}".encode()
except OSError:
data = b"no-fs-state"
return hashlib.sha256(data).digest()[:16]
def collect_entropy_witnesses(chain_db_path: Path) -> EntropyWitnesses:
"""Gather system entropy witnesses for an attestation chain record.
Args:
chain_db_path: Path to chain.bin, used for fs_snapshot.
Returns:
EntropyWitnesses with current system state.
"""
return EntropyWitnesses(
sys_uptime=time.monotonic(),
fs_snapshot=_get_fs_snapshot(chain_db_path),
proc_entropy=_get_proc_entropy(),
boot_id=_get_boot_id(),
)

View File

@@ -0,0 +1,57 @@
"""Data models for the attestation chain."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class EntropyWitnesses:
"""System-state snapshot collected at record creation time.
Serves as soft evidence that the claimed timestamp is plausible.
Fabricating convincing witnesses for a backdated record requires
simulating the full system state at the claimed time.
"""
sys_uptime: float
fs_snapshot: bytes # 16 bytes, truncated SHA-256
proc_entropy: int
boot_id: str
@dataclass(frozen=True)
class AttestationChainRecord:
"""A single record in the attestation hash chain.
Each record wraps content (typically a Verisoo attestation) with
a hash link to the previous record, entropy witnesses, and an
Ed25519 signature.
"""
version: int
record_id: bytes # UUID v7, 16 bytes
chain_index: int
prev_hash: bytes # SHA-256, 32 bytes
content_hash: bytes # SHA-256 of wrapped content, 32 bytes
content_type: str
metadata: dict = field(default_factory=dict)
claimed_ts: int = 0 # Unix microseconds
entropy_witnesses: EntropyWitnesses | None = None
signer_pubkey: bytes = b"" # Ed25519 raw public key, 32 bytes
signature: bytes = b"" # Ed25519 signature, 64 bytes
@dataclass
class ChainState:
"""Checkpoint of chain state, persisted to state.cbor."""
chain_id: bytes # SHA-256 of genesis record
head_index: int
head_hash: bytes
record_count: int
created_at: int # Unix µs
last_append_at: int # Unix µs
# Genesis prev_hash sentinel
GENESIS_PREV_HASH: bytes = b"\x00" * 32

View File

@@ -0,0 +1,97 @@
"""CBOR serialization for attestation chain records.
Uses canonical CBOR encoding (RFC 8949 §4.2) for deterministic hashing
and signing. Integer keys are used in CBOR maps for compactness.
"""
from __future__ import annotations
import hashlib
import cbor2
from soosef.federation.models import AttestationChainRecord, EntropyWitnesses
def _entropy_to_cbor_map(ew: EntropyWitnesses) -> dict:
"""Convert EntropyWitnesses to a CBOR-ready map with integer keys."""
return {
0: ew.sys_uptime,
1: ew.fs_snapshot,
2: ew.proc_entropy,
3: ew.boot_id,
}
def _cbor_map_to_entropy(m: dict) -> EntropyWitnesses:
"""Convert a CBOR map back to EntropyWitnesses."""
return EntropyWitnesses(
sys_uptime=m[0],
fs_snapshot=m[1],
proc_entropy=m[2],
boot_id=m[3],
)
def canonical_bytes(record: AttestationChainRecord) -> bytes:
"""Produce deterministic CBOR bytes for hashing and signing.
Includes all fields except signature. This is the input to both
Ed25519_Sign and SHA-256 for chain linking.
"""
m = {
0: record.version,
1: record.record_id,
2: record.chain_index,
3: record.prev_hash,
4: record.content_hash,
5: record.content_type,
6: record.metadata,
7: record.claimed_ts,
8: _entropy_to_cbor_map(record.entropy_witnesses) if record.entropy_witnesses else {},
9: record.signer_pubkey,
}
return cbor2.dumps(m, canonical=True)
def compute_record_hash(record: AttestationChainRecord) -> bytes:
"""SHA-256 of canonical_bytes(record). Used as prev_hash in next record."""
return hashlib.sha256(canonical_bytes(record)).digest()
def serialize_record(record: AttestationChainRecord) -> bytes:
"""Full CBOR serialization including signature. Used for storage."""
m = {
0: record.version,
1: record.record_id,
2: record.chain_index,
3: record.prev_hash,
4: record.content_hash,
5: record.content_type,
6: record.metadata,
7: record.claimed_ts,
8: _entropy_to_cbor_map(record.entropy_witnesses) if record.entropy_witnesses else {},
9: record.signer_pubkey,
10: record.signature,
}
return cbor2.dumps(m, canonical=True)
def deserialize_record(data: bytes) -> AttestationChainRecord:
"""Deserialize CBOR bytes to an AttestationChainRecord."""
m = cbor2.loads(data)
entropy_map = m.get(8, {})
entropy = _cbor_map_to_entropy(entropy_map) if entropy_map else None
return AttestationChainRecord(
version=m[0],
record_id=m[1],
chain_index=m[2],
prev_hash=m[3],
content_hash=m[4],
content_type=m[5],
metadata=m.get(6, {}),
claimed_ts=m.get(7, 0),
entropy_witnesses=entropy,
signer_pubkey=m.get(9, b""),
signature=m.get(10, b""),
)

View File

@@ -7,9 +7,11 @@ Requires GPS hardware or location services.
from __future__ import annotations
import json
import logging
import math
from dataclasses import dataclass
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -39,3 +41,50 @@ def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> fl
def is_inside(fence: GeoCircle, lat: float, lon: float) -> bool:
"""Check if a point is inside the geofence."""
return haversine_distance(fence.lat, fence.lon, lat, lon) <= fence.radius_m
def load_fence(path: Path | None = None) -> GeoCircle | None:
"""Load a saved geofence from disk. Returns None if no fence is configured."""
from soosef.paths import GEOFENCE_CONFIG
fence_path = path or GEOFENCE_CONFIG
if not fence_path.exists():
return None
with open(fence_path) as f:
data = json.load(f)
return GeoCircle(
lat=data["lat"],
lon=data["lon"],
radius_m=data["radius_m"],
name=data.get("name", "default"),
)
def save_fence(fence: GeoCircle, path: Path | None = None) -> None:
"""Persist a geofence to disk."""
from soosef.paths import GEOFENCE_CONFIG
fence_path = path or GEOFENCE_CONFIG
fence_path.parent.mkdir(parents=True, exist_ok=True)
with open(fence_path, "w") as f:
json.dump(
{
"lat": fence.lat,
"lon": fence.lon,
"radius_m": fence.radius_m,
"name": fence.name,
},
f,
indent=2,
)
def clear_fence(path: Path | None = None) -> bool:
"""Remove the saved geofence. Returns True if a fence was present and removed."""
from soosef.paths import GEOFENCE_CONFIG
fence_path = path or GEOFENCE_CONFIG
if fence_path.exists():
fence_path.unlink()
return True
return False

View File

@@ -19,16 +19,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from soosef.exceptions import KillswitchError
from soosef.paths import (
ATTESTATIONS_DIR,
AUTH_DB,
BASE_DIR,
CHANNEL_KEY_FILE,
CONFIG_FILE,
IDENTITY_DIR,
INSTANCE_DIR,
TEMP_DIR,
)
import soosef.paths as paths
logger = logging.getLogger(__name__)
@@ -95,17 +86,18 @@ def execute_purge(scope: PurgeScope = PurgeScope.ALL, reason: str = "manual") ->
logger.warning("KILLSWITCH ACTIVATED — reason: %s, scope: %s", reason, scope.value)
steps: list[tuple[str, callable]] = [
("destroy_identity_keys", lambda: _secure_delete_dir(IDENTITY_DIR)),
("destroy_channel_key", lambda: _secure_delete_file(CHANNEL_KEY_FILE)),
("destroy_flask_secret", lambda: _secure_delete_file(INSTANCE_DIR / ".secret_key")),
("destroy_identity_keys", lambda: _secure_delete_dir(paths.IDENTITY_DIR)),
("destroy_channel_key", lambda: _secure_delete_file(paths.CHANNEL_KEY_FILE)),
("destroy_flask_secret", lambda: _secure_delete_file(paths.INSTANCE_DIR / ".secret_key")),
]
if scope == PurgeScope.ALL:
steps.extend([
("destroy_auth_db", lambda: _secure_delete_file(AUTH_DB)),
("destroy_attestation_log", lambda: _secure_delete_dir(ATTESTATIONS_DIR)),
("destroy_temp_files", lambda: _secure_delete_dir(TEMP_DIR)),
("destroy_config", lambda: _secure_delete_file(CONFIG_FILE)),
("destroy_auth_db", lambda: _secure_delete_file(paths.AUTH_DB)),
("destroy_attestation_log", lambda: _secure_delete_dir(paths.ATTESTATIONS_DIR)),
("destroy_chain_data", lambda: _secure_delete_dir(paths.CHAIN_DIR)),
("destroy_temp_files", lambda: _secure_delete_dir(paths.TEMP_DIR)),
("destroy_config", lambda: _secure_delete_file(paths.CONFIG_FILE)),
("clear_journald", _clear_system_logs),
])

View File

@@ -10,17 +10,19 @@ from __future__ import annotations
import os
from pathlib import Path
import soosef.paths as _paths
from soosef.exceptions import KeystoreError
from soosef.keystore.models import IdentityInfo, KeystoreStatus
from soosef.paths import CHANNEL_KEY_FILE, IDENTITY_DIR, IDENTITY_PRIVATE_KEY, IDENTITY_PUBLIC_KEY
from soosef.keystore.models import IdentityInfo, KeystoreStatus, RotationResult
class KeystoreManager:
"""Manages all key material for a SooSeF instance."""
def __init__(self, identity_dir: Path | None = None, channel_key_file: Path | None = None):
self._identity_dir = identity_dir or IDENTITY_DIR
self._channel_key_file = channel_key_file or CHANNEL_KEY_FILE
# Use lazy path resolution so that --data-dir / SOOSEF_DATA_DIR overrides
# propagate correctly when paths.BASE_DIR is changed at runtime.
self._identity_dir = identity_dir or _paths.IDENTITY_DIR
self._channel_key_file = channel_key_file or _paths.CHANNEL_KEY_FILE
# ── Verisoo Identity (Ed25519) ──────────────────────────────────
@@ -28,6 +30,10 @@ class KeystoreManager:
"""Check if an Ed25519 identity exists."""
return (self._identity_dir / "private.pem").exists()
def _identity_meta_path(self) -> Path:
"""Path to the identity creation-timestamp sidecar file."""
return self._identity_dir / "identity.meta.json"
def get_identity(self) -> IdentityInfo:
"""Get identity info. Raises KeystoreError if no identity exists."""
pub_path = self._identity_dir / "public.pem"
@@ -36,8 +42,11 @@ class KeystoreManager:
if not pub_path.exists():
raise KeystoreError("No identity found. Run 'soosef init' to generate one.")
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from cryptography.hazmat.primitives.serialization import (
Encoding,
PublicFormat,
load_pem_public_key,
)
pub_pem = pub_path.read_bytes()
public_key = load_pem_public_key(pub_pem)
@@ -47,14 +56,53 @@ class KeystoreManager:
fingerprint = hashlib.sha256(pub_raw).hexdigest()[:32]
# Resolve created_at from the sidecar written by generate_identity().
# Fall back to private key mtime for keys generated before the sidecar
# was introduced (legacy compatibility).
from datetime import UTC, datetime
created_at: datetime | None = None
meta_path = self._identity_meta_path()
if meta_path.exists():
try:
import json
meta = json.loads(meta_path.read_text())
created_at = datetime.fromisoformat(meta["created_at"])
except Exception:
pass # malformed sidecar — fall through to mtime
if created_at is None and priv_path.exists():
created_at = datetime.fromtimestamp(priv_path.stat().st_mtime, tz=UTC)
return IdentityInfo(
fingerprint=fingerprint,
public_key_pem=pub_pem.decode(),
created_at=created_at,
has_private_key=priv_path.exists(),
)
def _archive_dir_for(self, parent: Path) -> Path:
"""Return a timestamped archive subdirectory under *parent*/archived/.
The timestamp uses ISO-8601 basic format (no colons) so the directory
name is safe on all filesystems: ``archived/2026-04-01T120000Z``.
"""
from datetime import UTC, datetime
ts = datetime.now(UTC).strftime("%Y-%m-%dT%H%M%S_%fZ")
return parent / "archived" / ts
def generate_identity(self, password: bytes | None = None) -> IdentityInfo:
"""Generate a new Ed25519 keypair."""
"""Generate a new Ed25519 keypair.
Security note: the private key is stored unencrypted by default.
This is intentional — the killswitch (secure deletion) is the
primary defense for at-risk users, not key encryption. A password-
protected key would require prompting on every attestation and
chain operation, which is unworkable in field conditions. The
key file is protected by 0o600 permissions.
"""
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import (
BestAvailableEncryption,
@@ -81,8 +129,68 @@ class KeystoreManager:
pub_path = self._identity_dir / "public.pem"
pub_path.write_bytes(pub_pem)
# Write creation timestamp sidecar so get_identity() always returns an
# authoritative created_at without relying on filesystem mtime.
import json
from datetime import UTC, datetime
meta_path = self._identity_meta_path()
meta_path.write_text(
json.dumps({"created_at": datetime.now(UTC).isoformat()}, indent=None)
)
return self.get_identity()
def rotate_identity(self, password: bytes | None = None) -> RotationResult:
"""Rotate the Ed25519 identity keypair.
The current private and public keys are copied verbatim to a
timestamped archive directory before the new keypair is generated.
Both old and new fingerprints are returned so the caller can report
them and prompt the user to notify collaborators.
Raises KeystoreError if no identity exists yet (use generate_identity
for initial setup).
"""
import shutil
if not self.has_identity():
raise KeystoreError("No identity to rotate. Run 'soosef init' first.")
old_info = self.get_identity()
# Archive the current keypair under identity/archived/<timestamp>/
archive_dir = self._archive_dir_for(self._identity_dir)
archive_dir.mkdir(parents=True, exist_ok=True)
archive_dir.chmod(0o700)
priv_src = self._identity_dir / "private.pem"
pub_src = self._identity_dir / "public.pem"
meta_src = self._identity_meta_path()
shutil.copy2(priv_src, archive_dir / "private.pem")
(archive_dir / "private.pem").chmod(0o600)
shutil.copy2(pub_src, archive_dir / "public.pem")
if meta_src.exists():
shutil.copy2(meta_src, archive_dir / "identity.meta.json")
# Write a small provenance note alongside the archived key so an
# operator can reconstruct the rotation timeline without tooling.
from datetime import UTC, datetime
(archive_dir / "rotation.txt").write_text(
f"Rotated at: {datetime.now(UTC).isoformat()}\n"
f"Old fingerprint: {old_info.fingerprint}\n"
)
new_info = self.generate_identity(password=password)
return RotationResult(
old_fingerprint=old_info.fingerprint,
new_fingerprint=new_info.fingerprint,
archive_path=archive_dir,
)
# ── Stegasoo Channel Key ────────────────────────────────────────
def has_channel_key(self) -> bool:
@@ -115,6 +223,59 @@ class KeystoreManager:
self.set_channel_key(key)
return key
def rotate_channel_key(self) -> RotationResult:
"""Rotate the Stegasoo channel key.
The current key is copied to a timestamped archive directory before
the new key is generated. Both old and new channel fingerprints are
returned.
Raises KeystoreError if no channel key exists yet (use
generate_channel_key for initial setup).
"""
import shutil
if not self.has_channel_key():
raise KeystoreError("No channel key to rotate. Run 'soosef init' first.")
# Only file-based keys can be archived; env-var keys have no on-disk
# representation to back up, so we refuse rather than silently skip.
if not self._channel_key_file.exists():
raise KeystoreError(
"Channel key is set via STEGASOO_CHANNEL_KEY environment variable "
"and cannot be rotated through soosef. Unset the variable and store "
"the key in the keystore first."
)
from stegasoo.crypto import get_channel_fingerprint
old_key = self._channel_key_file.read_text().strip()
old_fp = get_channel_fingerprint(old_key)
# Archive under stegasoo/archived/<timestamp>/channel.key
archive_dir = self._archive_dir_for(self._channel_key_file.parent)
archive_dir.mkdir(parents=True, exist_ok=True)
archive_dir.chmod(0o700)
shutil.copy2(self._channel_key_file, archive_dir / "channel.key")
(archive_dir / "channel.key").chmod(0o600)
from datetime import UTC, datetime
(archive_dir / "rotation.txt").write_text(
f"Rotated at: {datetime.now(UTC).isoformat()}\n"
f"Old fingerprint: {old_fp}\n"
)
new_key = self.generate_channel_key()
new_fp = get_channel_fingerprint(new_key)
return RotationResult(
old_fingerprint=old_fp,
new_fingerprint=new_fp,
archive_path=archive_dir,
)
# ── Unified Status ──────────────────────────────────────────────
def status(self) -> KeystoreStatus:

View File

@@ -2,6 +2,7 @@
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
@dataclass
@@ -22,3 +23,12 @@ class KeystoreStatus:
identity_fingerprint: str | None
has_channel_key: bool
channel_fingerprint: str | None
@dataclass
class RotationResult:
"""Result of a key rotation operation."""
old_fingerprint: str
new_fingerprint: str
archive_path: Path

View File

@@ -4,8 +4,12 @@ Centralized path constants for SooSeF.
All ~/.soosef/* paths are defined here. Every module that needs a path
imports from this module — no hardcoded paths anywhere else.
The base directory can be overridden via SOOSEF_DATA_DIR environment variable
for multi-instance deployments or testing.
The base directory can be overridden via:
- SOOSEF_DATA_DIR environment variable (before import)
- Setting paths.BASE_DIR at runtime (e.g., CLI --data-dir flag)
All derived paths (IDENTITY_DIR, CHAIN_DIR, etc.) are computed lazily
from BASE_DIR so that runtime overrides propagate correctly.
"""
import os
@@ -14,68 +18,90 @@ from pathlib import Path
# Allow override for testing or multi-instance deployments
BASE_DIR = Path(os.environ.get("SOOSEF_DATA_DIR", Path.home() / ".soosef"))
# Ed25519 identity keypair (verisoo signing)
IDENTITY_DIR = BASE_DIR / "identity"
IDENTITY_PRIVATE_KEY = IDENTITY_DIR / "private.pem"
IDENTITY_PUBLIC_KEY = IDENTITY_DIR / "public.pem"
# Path definitions relative to BASE_DIR. These are resolved lazily via
# __getattr__ so that changes to BASE_DIR propagate to all derived paths.
_PATH_DEFS: dict[str, tuple[str, ...]] = {
# Ed25519 identity keypair (verisoo signing)
"IDENTITY_DIR": ("identity",),
"IDENTITY_PRIVATE_KEY": ("identity", "private.pem"),
"IDENTITY_PUBLIC_KEY": ("identity", "public.pem"),
# Sidecar metadata written by generate_identity(); stores creation timestamp
# so get_identity() can return an authoritative created_at without relying
# on fragile filesystem mtime.
"IDENTITY_META": ("identity", "identity.meta.json"),
# Stegasoo state
"STEGASOO_DIR": ("stegasoo",),
"CHANNEL_KEY_FILE": ("stegasoo", "channel.key"),
# Verisoo attestation storage
"ATTESTATIONS_DIR": ("attestations",),
"ATTESTATION_LOG": ("attestations", "log.bin"),
"ATTESTATION_INDEX": ("attestations", "index"),
"PEERS_FILE": ("attestations", "peers.json"),
# Web UI auth database
"AUTH_DIR": ("auth",),
"AUTH_DB": ("auth", "soosef.db"),
# SSL certificates
"CERTS_DIR": ("certs",),
"SSL_CERT": ("certs", "cert.pem"),
"SSL_KEY": ("certs", "key.pem"),
# Fieldkit state
"FIELDKIT_DIR": ("fieldkit",),
"FIELDKIT_CONFIG": ("fieldkit", "config.json"),
"DEADMAN_STATE": ("fieldkit", "deadman.json"),
"TAMPER_DIR": ("fieldkit", "tamper"),
"TAMPER_BASELINE": ("fieldkit", "tamper", "baseline.json"),
"USB_DIR": ("fieldkit", "usb"),
"USB_WHITELIST": ("fieldkit", "usb", "whitelist.json"),
"GEOFENCE_CONFIG": ("fieldkit", "geofence.json"),
# Attestation hash chain
"CHAIN_DIR": ("chain",),
"CHAIN_DB": ("chain", "chain.bin"),
"CHAIN_STATE": ("chain", "state.cbor"),
# Ephemeral
"TEMP_DIR": ("temp",),
# Structured audit trail (append-only JSON-lines).
# Lives directly under BASE_DIR so it is destroyed by the killswitch along
# with everything else — intentional, per the security model.
"AUDIT_LOG": ("audit.jsonl",),
# Flask instance path (sessions, secret key)
"INSTANCE_DIR": ("instance",),
"SECRET_KEY_FILE": ("instance", ".secret_key"),
# Unified config
"CONFIG_FILE": ("config.json",),
}
# Stegasoo state
STEGASOO_DIR = BASE_DIR / "stegasoo"
CHANNEL_KEY_FILE = STEGASOO_DIR / "channel.key"
# Verisoo attestation storage
ATTESTATIONS_DIR = BASE_DIR / "attestations"
ATTESTATION_LOG = ATTESTATIONS_DIR / "log.bin"
ATTESTATION_INDEX = ATTESTATIONS_DIR / "index"
PEERS_FILE = ATTESTATIONS_DIR / "peers.json"
# Web UI auth database
AUTH_DIR = BASE_DIR / "auth"
AUTH_DB = AUTH_DIR / "soosef.db"
# SSL certificates
CERTS_DIR = BASE_DIR / "certs"
SSL_CERT = CERTS_DIR / "cert.pem"
SSL_KEY = CERTS_DIR / "key.pem"
# Fieldkit state
FIELDKIT_DIR = BASE_DIR / "fieldkit"
FIELDKIT_CONFIG = FIELDKIT_DIR / "config.json"
DEADMAN_STATE = FIELDKIT_DIR / "deadman.json"
TAMPER_DIR = FIELDKIT_DIR / "tamper"
TAMPER_BASELINE = TAMPER_DIR / "baseline.json"
USB_DIR = FIELDKIT_DIR / "usb"
USB_WHITELIST = USB_DIR / "whitelist.json"
# Ephemeral
TEMP_DIR = BASE_DIR / "temp"
# Flask instance path (sessions, secret key)
INSTANCE_DIR = BASE_DIR / "instance"
SECRET_KEY_FILE = INSTANCE_DIR / ".secret_key"
# Unified config
CONFIG_FILE = BASE_DIR / "config.json"
def __getattr__(name: str) -> Path:
"""Resolve derived paths lazily from current BASE_DIR."""
if name in _PATH_DEFS:
return Path(BASE_DIR, *_PATH_DEFS[name])
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def ensure_dirs() -> None:
"""Create all required directories with appropriate permissions."""
dirs = [
BASE_DIR,
IDENTITY_DIR,
STEGASOO_DIR,
ATTESTATIONS_DIR,
AUTH_DIR,
CERTS_DIR,
FIELDKIT_DIR,
TAMPER_DIR,
USB_DIR,
TEMP_DIR,
INSTANCE_DIR,
__getattr__("IDENTITY_DIR"),
__getattr__("STEGASOO_DIR"),
__getattr__("ATTESTATIONS_DIR"),
__getattr__("CHAIN_DIR"),
__getattr__("AUTH_DIR"),
__getattr__("CERTS_DIR"),
__getattr__("FIELDKIT_DIR"),
__getattr__("TAMPER_DIR"),
__getattr__("USB_DIR"),
__getattr__("TEMP_DIR"),
__getattr__("INSTANCE_DIR"),
]
for d in dirs:
d.mkdir(parents=True, exist_ok=True)
# Restrict permissions on sensitive directories
for d in [BASE_DIR, IDENTITY_DIR, AUTH_DIR, CERTS_DIR]:
for d in [
BASE_DIR,
__getattr__("IDENTITY_DIR"),
__getattr__("AUTH_DIR"),
__getattr__("CERTS_DIR"),
]:
d.chmod(0o700)