Skip to content

Security: Anichris-koded/Ledgerlens-data

Security

docs/security.md

LedgerLens Security

Threat Model: Model Poisoning

LedgerLens is a fraud-detection system — making it a high-value target for adversaries who want their wash trading to go undetected. Three attack vectors are in scope:

Threat Description
Artifact substitution A compromised CI pipeline or model storage replaces a legitimate .joblib with a backdoored one.
Training data poisoning An adversary injects fraudulent wash-trade labels into the annotation queue, causing the retrained model to develop a blind spot.
Ensemble manipulation If one of RF/XGBoost/LightGBM is compromised, a naive average gives the poisoned model equal weight, potentially reducing the final score by ~33 points.

Artifact Integrity Verification

Every model artifact goes through a four-step trust chain enforced by ModelArtifact.verify_chain() in detection/persistence.py:

  1. SHA-256 match — the .joblib file's SHA-256 must match the artifact_sha256 field recorded in metrics.json at training time.
  2. Ed25519 signaturemetrics.json must be accompanied by metrics.json.sig, a detached Ed25519 signature produced by the authorised signing key.
  3. Key fingerprint — the SHA-256 fingerprint of the public key used for verification must match TRUSTED_SIGNING_KEY_FINGERPRINT in config.
  4. Training data SHA-256 — (optional, supplied at call site) the SHA-256 of the training dataset recorded in metrics.json must match the caller's expectation.

A ModelIntegrityError with a specific failure reason is raised on any step failure. RiskScorer._load_models() calls verify_chain immediately after every joblib.load; a CI grep check enforces this invariant.

Generating a Signing Key

# Generate an Ed25519 private key (PEM format)
openssl genpkey -algorithm ed25519 -out signing_key.pem

# Extract the corresponding public key
openssl pkey -in signing_key.pem -pubout -out signing_key_pub.pem

Set MODEL_SIGNING_PRIVATE_KEY_PATH=./signing_key.pem in your environment (not in .env committed to git).

Computing the Trusted Fingerprint

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
import hashlib

with open("signing_key_pub.pem", "rb") as f:
    pub = serialization.load_pem_public_key(f.read())

raw = pub.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
fingerprint = hashlib.sha256(raw).hexdigest()
print(fingerprint)

Set TRUSTED_SIGNING_KEY_FINGERPRINT=<output> in your environment.

Signing Key Rotation

When rotating the Ed25519 signing key:

  1. Generate the new key pair (see above).
  2. Update MODEL_SIGNING_PRIVATE_KEY_PATH in CI secrets / deployment environment.
  3. Update TRUSTED_SIGNING_KEY_FINGERPRINT to the new public key's fingerprint.
  4. Re-run python -m detection.model_training --data-path ... to produce freshly signed artifacts.
  5. Deploy the new artifacts. Old artifacts signed with the previous key will fail verify_chain and must not be loaded.
  6. Revoke access to the old private key and delete it from all locations.

Never commit signing_key.pem or its contents to version control.

Byzantine-Fault-Tolerant Ensemble Voting

The inference stack uses a trimmed-mean / median voting scheme so that a single compromised model cannot materially change the final score.

Algorithm

  1. Collect the raw 0–100 scores from RF, XGBoost, and LightGBM.
  2. If |max - min| > BFT_SCORE_DIVERGENCE_THRESHOLD (default 30): log a WARNING with all three raw scores, increment the bft_divergence_detected_total Prometheus counter, and set bft_divergence: true in the response. Use the median as the final score (for 3 models this is the trimmed mean with the extremes dropped).
  3. If fewer than BFT_MIN_CONSENSUS (default 2) models agree within 10 points: return score=100, confidence=0, consensus_failure=true.

Tuning

Config var Default Effect
BFT_SCORE_DIVERGENCE_THRESHOLD 30 Minimum score span that triggers trimmed-mean fallback
BFT_MIN_CONSENSUS 2 Minimum number of models required to be within 10 points of each other

Training Data Provenance

detection/model_training.py records the following for every training run in metrics.json:

  • training_data_sha256 — SHA-256 of the row-sorted input parquet (deterministic).
  • label_distribution{0: N, 1: M} counts; a sudden shift in the 1:0 ratio is a poisoning signal.

Label Poisoning Detection

detect_label_poisoning() compares the current wash-trade label ratio against a baseline stored in models/label_distribution_baseline.json. If the ratio has shifted by more than POISON_LABEL_RATIO_THRESHOLD (default 15%), training is aborted and an alert is written to reports/poisoning_alert_{timestamp}.json.

Annotation Queue Integrity

Each entry in data/annotation_queue.json carries an annotation_hmac field: HMAC-SHA256 of wallet|label|annotator_id|annotated_at keyed by ANNOTATION_HMAC_SECRET. export_labelled() verifies every HMAC before including an annotation; tampered entries are logged as WARNING and excluded.

Set ANNOTATION_HMAC_SECRET to a cryptographically random value (≥ 32 bytes hex) and never commit it.

python -c "import secrets; print(secrets.token_hex(32))"

There aren't any published security advisories