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. |
Every model artifact goes through a four-step trust chain enforced by ModelArtifact.verify_chain() in detection/persistence.py:
- SHA-256 match — the
.joblibfile's SHA-256 must match theartifact_sha256field recorded inmetrics.jsonat training time. - Ed25519 signature —
metrics.jsonmust be accompanied bymetrics.json.sig, a detached Ed25519 signature produced by the authorised signing key. - Key fingerprint — the SHA-256 fingerprint of the public key used for verification must match
TRUSTED_SIGNING_KEY_FINGERPRINTin config. - Training data SHA-256 — (optional, supplied at call site) the SHA-256 of the training dataset recorded in
metrics.jsonmust 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.
# 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.pemSet MODEL_SIGNING_PRIVATE_KEY_PATH=./signing_key.pem in your environment (not in .env committed to git).
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.
When rotating the Ed25519 signing key:
- Generate the new key pair (see above).
- Update
MODEL_SIGNING_PRIVATE_KEY_PATHin CI secrets / deployment environment. - Update
TRUSTED_SIGNING_KEY_FINGERPRINTto the new public key's fingerprint. - Re-run
python -m detection.model_training --data-path ...to produce freshly signed artifacts. - Deploy the new artifacts. Old artifacts signed with the previous key will fail
verify_chainand must not be loaded. - Revoke access to the old private key and delete it from all locations.
Never commit signing_key.pem or its contents to version control.
The inference stack uses a trimmed-mean / median voting scheme so that a single compromised model cannot materially change the final score.
- Collect the raw 0–100 scores from RF, XGBoost, and LightGBM.
- If
|max - min| > BFT_SCORE_DIVERGENCE_THRESHOLD(default 30): log a WARNING with all three raw scores, increment thebft_divergence_detected_totalPrometheus counter, and setbft_divergence: truein the response. Use the median as the final score (for 3 models this is the trimmed mean with the extremes dropped). - If fewer than
BFT_MIN_CONSENSUS(default 2) models agree within 10 points: returnscore=100,confidence=0,consensus_failure=true.
| 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 |
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.
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.
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))"