A research-grade FastAPI demonstration where multiple banks collaboratively detect fraud across a shared, anonymous relationship graph — without ever exposing raw customer PII.
Privacy-preserving set intersection (PSI), homomorphic encryption (HE), differential privacy (DP), and graph-based fraud propagation, wired into a single runnable dashboard. Research/education only — not production-hardened.
- Highlights
- Screenshots
- Architecture (layers)
- Prerequisites
- Setup
- Run the app
- One-click demo
- Using the dashboard (
/ui/) - Seed / prune / re-seed
- Test
- Docker
- API reference (summary)
- References & sources
- Contributing
- License
- Anonymous by construction — raw PAN/Aadhaar/phone/email/name/address is never stored; only HMAC/blinded tokens, ciphertexts, and anonymous UUIDs.
- Cross-bank linkage with zero shared PII via ECDH PSI + MinHash/LSH fuzzy resolution and union-find consolidation.
- Encrypted risk attributes — Paillier additive HE (CKKS/TenSEAL when a wheel is available), decryptable only by the key-holder.
- DP-protected analytics with a persisted, enforced privacy budget (
429once exhausted). - Graph fraud propagation — weighted diffusion and Personalized PageRank over the anonymous relationship graph.
- One command to see it all — a single
/demo/runcall (or dashboard button) runs the full pipeline end to end.
📚 Documentation: docs/notes.md (deep architecture,
algorithms & citations) · docs/future.md (scaling, real-world
deployment & broader scope).
The full loop: seed → contribute (PSI) → resolve → graph → encrypt (HE) → propagate fraud → DP-protected analytics, with four enforced invariants:
- I1 — no raw PAN/Aadhaar/phone/email/name/address is ever stored; only HMAC/blinded tokens, ciphertexts, and anonymous UUIDs.
- I2 — ground-truth fraud labels live only in the synthetic-data module and are used only for validation, never by the live code path.
- I3 — every DP analytics query debits a persisted privacy budget; an
exhausted budget returns
429. - I4 — HE plaintext is recoverable only by the key-holder; keys never enter any table or API response.
routes (app/api/v1/*) → validate → call service → response envelope
services (app/services) → orchestrate use-cases, own engines via injected deps
engines (app/engines) → pure algorithms: PSI · resolution · HE · DP · graph
repositories (app/repos) → SQLite access + in-memory graph singleton
models / schemas → SQLAlchemy ORM · Pydantic DTOs
Storage: SQLite only (
data/consortium.db, file-backed, WAL). The graph is derived state rebuilt from SQLite on startup. SetDATABASE_URL=sqlite+aiosqlite:///:memory:for tests.Crypto backends degrade gracefully. CKKS (TenSEAL) has no Python 3.14 wheel yet, so the HE backend falls back to Paillier (
autodefault); setHE_BACKEND=nullfor fast, no-encryption runs. DP uses OpenDP.
Uses Python 3.14.2 and uv for fast, reproducible dependency management.
Install uv (see the docs):
curl -LsSf https://astral.sh/uv/install.sh | sh- Create a virtual environment with the pinned Python version:
uv venv --python 3.14.2- Activate it:
source .venv/bin/activate- Install dependencies:
uv pip install -r requirements.txtStart the FastAPI server with Uvicorn:
uv run uvicorn main:app --reloadThen open:
- Dashboard (Bootstrap + Cytoscape):
http://127.0.0.1:8000/ui/ - OpenAPI docs:
http://127.0.0.1:8000/docs - Health check:
http://127.0.0.1:8000/or/health
The fastest path — generate data, seed, resolve, encrypt, propagate, and run DP analytics in a single call (or click ⚡ One-click demo in the dashboard):
curl -X POST localhost:8000/api/v1/demo/run \
-H 'content-type: application/json' \
-d '{"banks":5,"customers":2000,"seed":7}'(Screenshots of every panel are at the top of this README.)
The dashboard is a single page split into a left control column and a right visualization column. A typical session:
- Click ⚡ One-click demo (top of Synthetic Data) — this generates data, seeds it, resolves identities, encrypts risk, runs propagation, and queries DP analytics. The graph, top-risk list, and metrics all populate.
- Pick a bank in the Active source dropdown (top-right) and use the Bank Console to push more records as that bank — simulating a second/third input feeding the same consortium.
- Re-run propagation, animate the spread, look up an exact risk, and query DP analytics until the budget is exhausted.
Navbar
| Control | What it does |
|---|---|
| Active source (dropdown) | Selects which registered bank you are "acting as". Every Bank Console action is submitted from this bank — the no-auth way to simulate multiple input sources. |
| status pill (right) | Shows the last API call and any error (e.g. budget exhausted). |
🏦 Bank Console — the active input source
| Control | What it does |
|---|---|
| Add (name + CODE) | Registers a new bank (POST /banks/register) and selects it as active. |
| local ref / phone / email / PAN + Submit record | Submits one raw record as the active bank. The values are HMAC-tokenized server-side at the bank boundary (the bank's local step) and then ingested — raw PII is never stored (POST /banks/{id}/simulate-contribution). Auto-runs resolution so cross-bank links form immediately. |
| Push 10 random | Generates 10 random tokenized records (chained by shares edges) and ingests them as the active bank — quick way to grow a source. |
| View this bank's batches | Lists the active bank's contribution batches (GET /banks/{id}/contributions). |
Try this: register
ALPHAandBETA, submit a record under ALPHA with phone9991112222, then one under BETA with999-111-2222. After resolution they collapse into one anonymous entity — cross-bank linkage with zero shared PII.
1 · Synthetic Data
| Control | What it does |
|---|---|
| Banks / Customers / Seed | Parameters for generation; the seed makes datasets reproducible. |
| ⚡ One-click demo | Runs the whole pipeline (POST /demo/run). |
| Generate | Writes CSV/JSON dataset files only (POST /datasets/generate). |
| Seed | Loads the dataset files through the real ingest + resolution pipeline (POST /datasets/seed). |
| Reset | Wipes all data and clears the graph, keeping HE keys (POST /admin/prune). Dataset files are preserved, so you can re-seed the same world. |
2 · Fraud Propagation
| Control | What it does |
|---|---|
| Algorithm | Weighted diffusion (bounded, decaying BFS from seeds) or Personalized PageRank (fraud-proximity stationary distribution). |
| Top K | How many highest-scoring nodes to return. |
| Run propagation | Seeds from filed fraud reports, scores every node, writes the scores back encrypted, and renders the top-risk list + the neighborhood of the riskiest node (POST /fraud/propagation/run). |
| ▶ Animate risk spread | Replays the diffusion frontier hop-by-hop over the rendered graph (client-side animation). |
3 · Risk Lookup (authorized)
| Control | What it does |
|---|---|
| anonymous entity id + Decrypt exact risk | Authorized, access-logged exact lookup — the key-holder decrypts that one entity's risk/exposure ciphertext (POST /queries/risk). Leave blank to use the top node. |
4 · DP Analytics & Budget
| Control | What it does |
|---|---|
| Query DP summary | Returns differentially-private consortium totals (Laplace-noised) and the remaining privacy budget (GET /analytics/summary, /analytics/budget/status). Repeated queries spend the budget; once exhausted the call returns 429 and the panel says so. |
Right column
| Panel | What it shows |
|---|---|
| Anonymous Relationship Graph | Cytoscape view of the riskiest node's 2-hop neighborhood; node colour = relative risk, edge width = relationship strength. Nodes are anonymous IDs only. |
| Top fraud-proximity | Ranked list of highest-scoring entities with score bars. |
| Consortium metrics | Banks/records/entities/edges, DP-noised counts, and validation precision/recall/F1 vs the synthetic ground truth. |
The SQLite store is disposable; the dataset files are the durable seed.
# 1. (Re)generate dataset files into demo/datasets/
uv run python scripts/regenerate_datasets.py # edit constants for scale
# 2. Prune ALL data (drops + recreates tables, clears graph; keeps HE keys)
uv run python scripts/reset.py # set RESET_KEYS=True to rotate
# 3. Re-seed from the dataset files through the real ingest pipeline
uv run python scripts/seed.pyEquivalent HTTP API (against a running server / the dashboard "Reset" button):
curl -X POST localhost:8000/api/v1/admin/prune -d '{"reset_keys":false}' -H 'content-type: application/json'
curl -X POST localhost:8000/api/v1/datasets/generate -d '{"banks":5,"customers":2000,"seed":7}' -H 'content-type: application/json'
curl -X POST localhost:8000/api/v1/datasets/seedHard reset: rm -f data/consortium.db* then restart (schema is recreated empty).
Incremental additions need no prune — POST /api/v1/banks/{id}/contributions
again (each call is a tracked batch; tokens dedupe) then POST /api/v1/entities/resolve.
uv run pytest -v # in-memory SQLite, null HE
uv run ruff check . && uv run mypy --explicit-package-bases .Build and run the API with Docker Compose:
docker compose up --buildThen open the same URLs as a local run — dashboard at http://127.0.0.1:8000/ui/,
docs at /docs.
What the image does:
- Multi-stage build on
python:3.14.2-slim; dependencies are installed withuvinto an isolated virtualenv, then copied into a lean runtime stage. - Runs as a non-root user and ships a
HEALTHCHECKagainst/health.
Persistence. Consortium state (the SQLite DB, HE keys, and snapshots under
data/) is stored in a named volume, so it survives down/up and rebuilds:
docker compose down # stops containers, keeps the volume + your data
docker compose down -v # also removes the volume — wipes all stateConfiguration. Compose reads an optional .env file (copy from
.env.sample). At minimum, override the tokenization secret for
any non-local use:
# .env
HMAC_SECRET=<a-long-random-secret>
HE_BACKEND=auto # auto | ckks | paillier | nullHOST, PORT, and DATABASE_URL are fixed to container-appropriate values in
docker-compose.yml, so anything you set for those in .env is ignored inside
the container.
All paths below are prefixed with /api/v1. All responses use the
{status, message, data} envelope; list endpoints paginate. Full interactive
docs at /docs.
| Area | Endpoints |
|---|---|
| Banks & contributions | POST /banks/register · GET /banks/list · DELETE /banks/{id}/remove · POST /banks/{id}/contributions (tokenized) · POST /banks/{id}/simulate-contribution (raw, tokenized server-side) · GET /banks/{id}/contributions · GET /contributions/{batch_id}/status |
| Entity resolution | POST /entities/resolve · GET /entities/list · GET /entities/{id}/profile · POST /entities/{id}/merge · POST /entities/{id}/split |
| Graph | GET /graph/nodes/{id}/neighborhood · GET /graph/stats · POST /graph/edges/add · DELETE /graph/edges/{id}/remove · POST /graph/rebuild |
| Fraud & queries | POST /fraud/reports · GET /fraud/reports/list · POST /fraud/propagation/run · GET /fraud/propagation/runs · POST /queries/risk |
| Analytics (DP) | GET /analytics/summary · GET /analytics/regions · GET /analytics/budget/status |
| Datasets / admin / demo | POST /datasets/generate · GET /datasets/list · POST /datasets/seed · POST /admin/prune · POST /admin/snapshot · POST /admin/restore · POST /demo/run |
- FastAPI + Uvicorn — async web framework / ASGI server.
- Pydantic v2 + pydantic-settings — validation & typed config.
- SQLAlchemy 2.x (async) + aiosqlite — ORM over SQLite.
- uv — environment & dependency management. Lint Ruff, types mypy, tests pytest + httpx.
- ECDH / DH-based PSI (
app/engines/psi/ecdh.py) — commutative double-blinding. Design mirrors OpenMined/PSI and IETF draft-wang-ppm-ecdh-psi. The prime-order group is RFC 3526 (2048-bit MODP). Foundational PSI: Meadows, A More Efficient Cryptographic Matchmaking Protocol (1986); Huberman, Franklin & Hogg (1999). - MinHash + LSH blocking (
app/engines/resolution/minhash_lsh.py) — Broder, On the Resemblance and Containment of Documents (MinHash, 1997); Indyk & Motwani, Approximate Nearest Neighbors (LSH, STOC 1998); Leskovec, Rajaraman & Ullman, Mining of Massive Datasets, ch. 3. Hashing via mmh3 (MurmurHash3). Private record linkage via LSH+PSI: arXiv:2203.14284. - Union-find (
app/engines/resolution/union_find.py) — Tarjan, Efficiency of a Good but Not Linear Set Union Algorithm (JACM 1975); path compression + union by rank. - Local tokenization (
utils/hashing.py) — HMAC-SHA256, RFC 2104 / FIPS 198-1.
- Paillier additive HE (
app/engines/he/paillier_phe.py) via python-paillier (phe) — Paillier, Public-Key Cryptosystems Based on Composite Degree Residuosity Classes (EUROCRYPT 1999). - CKKS approximate HE (
app/engines/he/ckks_tenseal.py) via TenSEAL — Cheon, Kim, Kim & Song, Homomorphic Encryption for Arithmetic of Approximate Numbers (ASIACRYPT 2017). Production successor OpenFHE (§13.2). Note: no TenSEAL wheel for Python 3.14 yet — the app falls back to Paillier.
- Mechanisms (
app/engines/dp/opendp_mech.py) via OpenDP (Harvard); alt IBM diffprivlib. Foundations: Dwork & Roth, The Algorithmic Foundations of Differential Privacy (2014); Dwork et al., Calibrating Noise to Sensitivity (TCC 2006). - zCDP budget accountant (
app/engines/dp/accountant.py) — Bun & Steinke, Concentrated Differential Privacy: Simplifications, Extensions, and Lower Bounds (TCC 2016, arXiv:1605.02065). SQL-level DP future option Tumult Analytics.
- NetworkX (
networkx) — graph store & algorithms. - Personalized PageRank (
app/engines/graph/pagerank.py) — Page, Brin, Motwani & Winograd, The PageRank Citation Ranking (1999); Haveliwala, Topic-Sensitive PageRank (WWW 2002). - Weighted BFS risk diffusion (
app/engines/graph/diffusion.py) — bounded, decaying label/influence spread (cf. independent-cascade / heat-diffusion models). - Future — GNNs (§13.3): GNNs for Financial Fraud Detection: A Review (arXiv:2411.05815); GraphSAGE (Hamilton et al., NeurIPS 2017); GAT (Veličković et al., ICLR 2018).
- Faker + NumPy — reproducible synthetic PII, planted cross-bank links, and fraud scenarios.
- Bootstrap 5 + Cytoscape.js (Franz et al., Bioinformatics 2016) — dashboard & graph rendering.
Threat model & invariants are summarized above and detailed in
docs/notes.md.
uv runexecutes commands inside the project environment automatically — no manual activation needed.- For production deployments, replace
--reloadwith a production-ready configuration.
Contributions are welcome! This is a research/education project, so improvements to correctness, clarity, and the cryptographic backends are especially valued.
- Fork the repo and create a feature branch.
- Set up the environment (see Setup) and install the pre-commit hooks:
uv run pre-commit install
- Make your change with tests, then run the checks:
uv run pytest -v uv run ruff check . && uv run mypy --explicit-package-bases .
- Open a pull request describing the change and, where relevant, which of the four invariants (I1–I4) it touches.
Please keep the privacy invariants intact — no raw PII should ever be persisted or returned by an endpoint.
Released under the MIT License.
Disclaimer: This project is a demonstration for research and educational purposes. It is not audited or hardened for production use with real personal or financial data.





