Blockchain-anchored document issuance and verification for institutional credentials.
MindForge lets an authorised institution issue a document whose integrity can later be verified by anyone — without the verifier needing access to the issuing institution's database, credentials, or systems.
The document itself never touches the blockchain. Only a cryptographic fingerprint does.
Read this first: what is built and what is a design. Parts of this README describe the intended system. The Implementation Status table says, capability by capability, what exists in the code today, and the flow diagrams are labelled implemented or target design. Where the two differ, the status table is the truth.
- The Problem
- How It Works
- Tamper Detection
- System Architecture
- Where Data Is Stored
- Data Flows
- Implementation Status
- Technology Stack
- Getting Started
- Environment Variables
- API Reference
- Security
- Compliance
- Troubleshooting
- Roadmap
India already has strong infrastructure for verifying documents the government issued digitally — DigiLocker serves over 100 million users, and the National Academic Depository covers degrees.
The gap is everything else:
- The paper certificate in someone's hand
- A scan that arrived over WhatsApp, recompressed three times
- A photograph of a printout handed across a desk
- A deed produced by an office that never digitised
For these, verification is still manual, slow, and dependent on trusting whoever is holding the paper. MindForge targets that gap specifically.
What this does not do. A blockchain match proves a document corresponds to a fingerprint previously recorded by an authorised issuer. It does not prove the real-world facts inside the document were true when issued. Integrity is not veracity.
A single hash can only answer one question. MindForge computes three, because verification and tamper-detection are different problems.
| Tier | Fingerprint | Question it answers | Authority |
|---|---|---|---|
| 1 | SHA-256 of raw bytes | Is this the exact original file? | Proof |
| 2 | Canonical content hash | Is this the same document? | Primary verdict |
| 3 | Perceptual hash | Does it look consistent? | Advisory only |
Tier 1 is absolute but brittle — re-save a PDF, photograph a certificate, or forward it over WhatsApp and the bytes change, so a genuine document fails.
Tier 2 solves that. The document is OCR'd, its fields extracted and normalised, and that record is hashed. Pixels are discarded before hashing, so compression, resizing, mild rotation, lighting and viewing angle largely stop mattering (measured on simulated copies of one template, not yet on real phones) — but altering a name or a date changes the hash deterministically, and we can name the field that moved. It reads one controlled certificate template, and images only (not PDFs).
Tier 3 catches what Tier 2 cannot: a substituted photograph, where the text reads identically but the appearance has changed.
Why Tier 3 is never the authority. Every ID of a given type shares one template. After perceptual hashing downsamples and discards high-frequency detail, a different person's card lands within a few bits of yours. Used as the deciding vote it would verify your friend's Aadhaar as your own. It may confirm or flag — never approve.
Four properties follow from this design:
| Property | Why |
|---|---|
| Privacy-preserving | No document content and no personal data on-chain. Only hashes. |
| Constant on-chain size | The on-chain record is 32 bytes whatever the document's size. (Uploads to this system are limited to PDF, PNG and JPEG up to 10 MB.) |
| Independently verifiable | The public chain can be read directly: no API key, no account. (Reproducing a Tier 2 hash needs the same field extraction this system performs.) |
| Resilient | In simulated tests a genuine copy that was recompressed, shrunk, rotated, dimmed or photographed at an angle still matched; two extreme cases (heavy blur, 25° rotation) were reported inconclusive and none was wrongly called altered. Real phone captures have not been measured. |
The verdict comes from how the three tiers disagree, not from any one of them.
| Tier 1 | Tier 2 | Tier 3 | Verdict | Meaning |
|---|---|---|---|---|
| ✅ | ✅ | ✅ | AUTHENTIC_ORIGINAL |
Untouched original file |
| ❌ | ✅ | close | AUTHENTIC_COPY |
Scan, photo or forward — content intact |
| ❌ | ✅ | far | TAMPERED_VISUAL |
Text identical, appearance changed → photo substitution |
| ❌ | ❌ | any | TAMPERED_CONTENT |
A field was altered — reported by name |
| ❌ | any | INCONCLUSIVE |
OCR unreliable; a better capture is requested |
Row 3 is the point of the design: neither byte-hashing nor content-hashing alone detects a swapped photograph. Their disagreement does.
Whole-image comparison only says something changed. To say where, the normalised document is tiled into a 4×4 grid and each cell hashed independently:
┌────┬────┬────┬────┐
│ ok │ ok │ ok │ ok │
├────┼────┼────┼────┤
│🔴18│ ok │ ok │ ok │ ← photo region, Hamming distance 18
├────┼────┼────┼────┤ every other cell ≤ 4
│ ok │ ok │ ok │ ok │
└────┴────┴────┴────┘
One cell diverging while the rest stay tight indicates a localised edit. Many cells diverging at once is reported as unclear: it could be a bad capture or a different document, and a disturbed picture is never read as "just a re-capture, so fine". Because a photo box occupies only part of its grid tile, the template's photo region is also hashed on its own, which separates a swapped photo far more clearly. Per-cell distances are computed for a heatmap (backend/phash.js); the heatmap UI itself is not built yet.
The verdict engine (backend/forensics.js) returns a structured report like this one. It is implemented and unit-tested, but not yet connected to a verification endpoint (#66).
{
"verdict": "TAMPERED_CONTENT",
"confidence": "HIGH",
"tiers": {
"byte": { "match": false },
"content": { "match": false,
"fieldDiffs": [
{ "field": "dob", "anchored": "2005-04-12", "presented": "2003-04-12" }
]},
"visual": { "distance": 6, "regions": [[2,3,2,4],[18,3,2,3],[3,2,4,3],[2,3,3,2]] }
},
"anchor": { "issuer": "NITC Registrar", "issuedAt": "2026-10-09", "txHash": "0x8f2e…" }
}flowchart TB
subgraph clients [" "]
OFF["👤 Issuing Officer"]
PUB["🌐 Public Verifier"]
end
subgraph app ["Application Layer"]
FE["React + Vite<br/>Issuer portal · Verify portal"]
API["Node.js + Express<br/>REST API"]
end
subgraph svc ["Verification Engine"]
PRE["Preprocess<br/>edge detect · deskew · normalise"]
T1["Tier 1 · SHA-256<br/>raw bytes"]
T2["Tier 2 · Content Hash<br/>OCR → fields → canonical"]
T3["Tier 3 · Perceptual Hash<br/>whole + 4×4 grid"]
VERD["Verdict Engine<br/>matrix · field diff · heatmap"]
AUTH["Auth<br/>JWT · bcrypt"]
end
subgraph data ["Persistence"]
S3[("AWS S3<br/>ap-south-1<br/>document bytes")]
DB[("MongoDB Atlas<br/>ap-south-1<br/>metadata · audit")]
end
subgraph chain ["Trust Layer"]
RPC["Polygon RPC"]
SC["CredentialRegistry<br/>Solidity"]
end
OFF --> FE
PUB --> FE
FE -->|HTTPS| API
API --> AUTH
API --> PRE
PRE --> T1 & T2 & T3
T1 & T2 & T3 --> VERD
VERD --> API
API --> S3
API --> DB
API --> RPC
RPC --> SC
T1 -.->|anchored| SC
T2 -.->|anchored| SC
T3 -.->|stored off-chain<br/>fuzzy values are not proofs| DB
classDef store fill:#1a3a52,stroke:#4a90d9,color:#fff
classDef trust fill:#3d2a52,stroke:#9d6ad9,color:#fff
classDef tier fill:#4a3015,stroke:#d9a04a,color:#fff
class S3,DB store
class RPC,SC trust
class T1,T2,T3,VERD tier
Note which tiers are anchored. Tiers 1 and 2 are deterministic, so they go on-chain as proofs. Tier 3 is a similarity score with a tunable threshold — it lives in MongoDB, because a fuzzy value anchored in an immutable ledger would imply a certainty it does not have.
This diagram is the target architecture. Not yet connected: the Verdict Engine to the API, backend-side anchoring (the officer's wallet signs today), and the new CredentialRegistry (written and tested in blockchain/, not yet deployed; the frontend still calls the old contract).
Design rule: the backend is the only component holding secrets. The browser never sees a database URI or a cloud credential. Public verification requires no wallet and no browser extension. (Today the issuing officer's own wallet, such as MetaMask, signs the anchoring transaction in their browser; moving that signing to the backend is part of the target design.)
Each store holds exactly what it is good at. Nothing is duplicated across them.
flowchart LR
DOC["📄 degree.pdf"] --> H{"SHA-256"}
H -->|"the bytes"| S3[("AWS S3<br/>━━━━━━━<br/>Key = SHA-256 of the bytes<br/>SSE-AES256<br/>private · presigned")]
H -->|"facts about it"| DB[("MongoDB Atlas<br/>━━━━━━━━━<br/>hashes · s3Key · issuer<br/>txHash · status<br/>audit trail")]
H -->|"32-byte proof"| BC[("Polygon<br/>━━━━━━<br/>contentHash<br/>issuer address<br/>timestamp · revoked")]
classDef a fill:#1a3a52,stroke:#4a90d9,color:#fff
classDef b fill:#1a4a35,stroke:#4ad98a,color:#fff
classDef c fill:#3d2a52,stroke:#9d6ad9,color:#fff
class S3 a
class DB b
class BC c
| Store | Holds | Why not elsewhere |
|---|---|---|
| AWS S3 | The actual PDF / PNG / JPEG | MongoDB caps documents at 16 MB (BSON limit) and our M0 tier is 512 MB total — roughly 250 scanned PDFs before the cluster is full. S3 is ~10× cheaper per GB, serves downloads through short-lived presigned URLs (uploads do pass through the API for validation), and gives versioning, lifecycle rules, and object-level encryption. |
| MongoDB Atlas | Metadata, status, audit events | S3 is a key-value blob store — it can only answer "what are the bytes at key X?" It cannot answer "which documents did this officer issue?" or "has this hash already been anchored?" without scanning the entire bucket. |
| Polygon | 32-byte hash + issuer + timestamp | Storing a 1 MB file on-chain would cost millions in gas, be permanently public, and be impossible to erase. Only the integrity proof needs to be tamper-evident. |
The hash is the join key. The S3 object is keyed by the SHA-256 of the file's bytes (Tier 1). The MongoDB record holds that same hash plus the Tier 2 content hash, and the Tier 2 content hash is what the contract is keyed by:
MongoDB record S3 object Blockchain (CredentialRegistry)
────────────── ───────── ───────────────────────────────
sha256: "a17bc9…" ────► Key: "a17bc9…"
contentHash: "5e03d1…" ─────────────────────────► records["5e03d1…"]
s3Key: "a17bc9…" (the file bytes) { issuer, issuedAt, revoked, byteHash }
issuerName: "registrar"
txHash: "0x8f2e…"
status: "ISSUED"
What MongoDB does hold that is personal data. For a document issued with its details, MongoDB keeps the canonical fields (name, date of birth, ID number) so that a mismatch can say which field changed, old against new. That is personal data at rest, readable only by the issuer's authenticated session and never included in the record view returned to clients.
| Never stored | Where it would have leaked | Why |
|---|---|---|
| Document contents on-chain | Blockchain | Permanent, public, unerasable |
| Personal data on-chain | Blockchain | Conflicts with DPDP Act erasure rights |
| Secrets in MongoDB | Database | Credentials belong in env config only |
Secrets in VITE_* vars |
Frontend bundle | Vite inlines these into the browser — publishing them |
| Suspect files during verification | S3 | Verification hashes in memory and discards |
| Request bodies in logs | Log files | That would be document contents |
sequenceDiagram
autonumber
actor O as Officer
participant FE as Frontend
participant API as Backend
participant S3 as AWS S3
participant DB as MongoDB
participant BC as Polygon
O->>FE: Log in, select document (and its details)
FE->>API: POST /api/anchor (Bearer token)
API->>API: Validate type, magic bytes, size
API->>API: Compute SHA-256 (+ Tier 2 hash from the details,<br/>checked against the printed text for images)
API->>DB: Duplicate check (same bytes, or same content hash)
DB-->>API: Not found — proceed
API->>DB: Create record (status: PENDING)
API->>S3: PutObject (Key = hash, SSE on)
S3-->>API: Stored
API->>DB: status: STORED (+ Tier 3 look-hash, off-chain)
API-->>FE: hashes + short-lived signed link
O->>BC: Officer's wallet signs the anchoring transaction
FE->>API: POST /api/documents/:id/chain-pending (txHash)
API->>DB: status: BLOCKCHAIN_PENDING
FE->>API: POST /api/documents/:id/chain-confirmed
API->>BC: Read the receipt, check success and target contract
API->>DB: status: ISSUED + blockNumber
How it is wired. The officer's wallet signs the transaction, not the backend. The browser calls whichever CredentialRegistry is named by VITE_CONTRACT_ADDRESS / VITE_CHAIN_ID (no address is written in the code) and refuses to sign if the wallet is on another network. The server never trusts what the browser says happened: chain-confirmed re-reads the transaction and the registry and only then marks the document ISSUED. A transaction that succeeded but anchored different data is rejected as WRONG_DATA. When the document is issued the officer gets its QR code (pointers only: content hash, chain, contract).
Failure handling. The record is created PENDING before the upload, so a crash leaves a visible row rather than an invisible orphan. If a step fails the record is marked FAILED with a short reason code; the stored object is kept (its key is the content hash, so a retry reuses it). Re-submitting the same file, or a re-photographed copy of the same document, returns the existing record instead of issuing twice. npm run reconcile resolves records stuck part-way by asking the chain what happened.
POST /api/verifyruns this flow (backend/verification.js). Nothing is stored. The answer for registered and revoked always comes from the chain; if the chain cannot be reached the response sayschainChecked: falseinstead of pretending. A QR code is never trusted by itself: the server recomputes the document's own content hash and requires it to equal the code's (QR_MISMATCHotherwise).
sequenceDiagram
autonumber
actor V as Verifier
participant FE as Frontend
participant API as Backend
participant BC as Polygon
participant DB as MongoDB
V->>FE: Upload suspect document (or scan QR)
FE->>API: POST /api/verify (file, optional QR text)
Note over API: Everything below runs in memory.<br/>The file is never written to S3.
API->>API: Preprocess — deskew, perspective-correct
par Three tiers computed in parallel
API->>API: Tier 1 — SHA-256 of bytes
and
API->>API: Tier 2 — OCR → fields → canonical hash
and
API->>API: Tier 3 — pHash, whole image + 4×4 grid
end
API->>BC: verify(contentHash) — read-only, no gas
BC-->>API: exists · issuer · timestamp · revoked
API->>DB: Fetch anchored pHash + issuer name
API->>API: Verdict engine — apply matrix
alt Not registered
API-->>FE: ❌ NOT REGISTERED
else Revoked
API-->>FE: ⚠️ REVOKED + reason
else Byte match
API-->>FE: ✅ AUTHENTIC — original file
else Content match, visual close
API-->>FE: ✅ AUTHENTIC — re-captured copy
else Content match, visual far
API-->>FE: 🔴 TAMPERED — visual + region heatmap
else Content mismatch
API-->>FE: 🔴 TAMPERED — field diffs named
else Low OCR confidence
API-->>FE: ⚠️ INCONCLUSIVE — request better capture
end
Verification is a read-only chain call: no gas, no wallet, no account. Each verification attempt is recorded in the audit trail (action, verdict, truncated network address; never the file or its hash). The public sees which field changed but not the registered value (it is personal data); a logged-in issuer sees both.
What Tier 3 can and cannot check. The photo region is compared only when its hash is stable under ordinary re-capture. For a flat, hard-edged graphic (such as the placeholder portraits in the test certificates) it is not, so the verdict says the photo area "was not checked" rather than flagging a legitimate JPEG copy. This was found by driving the real UI and is explained in backend/TIER3.md.
Status is tracked honestly so that documentation never overstates the code. ✅ built and tested · 🟡 built, with the stated gap · ❌ not built.
| Capability | Status | Detail | Tracking |
|---|---|---|---|
| Tier 1 — byte hash (SHA-256) | ✅ | POST /api/hash (stores nothing) and POST /api/anchor |
— |
| Authentication | 🟡 | bcrypt + short-lived JWT for one account set in the environment; no user database, password reset or per-user roles | — |
| Upload validation, HTTP hardening, redacted logging | ✅ | type + magic-byte check, size cap, helmet, CORS allowlist, rate limits, secrets scrubbed from logs | — |
| S3 storage | 🟡 | content-addressed keys, server-side encryption, ≤5-minute signed links, npm run check-bucket. Tested with a fake client; never run against a real bucket |
— |
| MongoDB layer | ✅ | document model, issuance state machine with recovery sweep, audit trail with expiry. Tested against a real MongoDB | — |
| Tier 2 — canonical content hash | 🟡 | OCR, flattening, label-anchored extraction, field-level diff, anchored on chain next to the byte hash; one template, images only; proven on simulated copies, not real phones. Two implementations currently exist (backend/*.js and backend/src/services/); one will be retired |
#52 |
| Tier 3 — perceptual hash + region grid | 🟡 | built, stored off-chain, used by POST /api/verify as advice only; thresholds measured on simulated data; a photo region that is not stable under re-capture is reported as unchecked |
#65 |
| Tamper forensics / verdict engine | ✅ | POST /api/verify returns one of eight verdicts with field diffs, an appearance map and the anchor; the seven adversarial cases are tested end to end with real images and a real database (the chain is scripted in those tests, real in blockchain/test) |
#66 |
| On-chain issuer authorisation | 🟡 | blockchain/ contract, ABI and passing tests against a real EVM, including the real backend + MongoDB; the frontend and backend take the address from configuration. Not deployed to a public network, source not verified on an explorer. The old, ungated contract 0x1477… is on Ethereum Sepolia, not Polygon Amoy (checked by bytecode) and is no longer referenced by the code |
#62 |
| Revocation | ✅ | revoke() is sent by the issuer's wallet; the server reads the reason and time from the chain before mirroring it; the public verdict and the officer's page show both. Tested against a real EVM and in a real browser |
#59 |
| QR verification | ✅ | the code holds pointers only; verification recomputes the document's own hash and requires it to match (QR_MISMATCH). Scanning from a photo or the camera; tested by scanning the image the issuing page drew |
#58 |
| Officer dashboard, routing, error boundary | ✅ | login, paged document list with status counts and filter, issue form, document page (transaction link, QR, history, revoke), public verify page, not-found page, error boundary; 401 returns to login | #60 |
| Forgery test corpus | 🟡 | corpus/: 14 synthetic files (genuine, tampered L1-L3, negative controls), a manifest and npm run corpus, which prints a confusion matrix: genuine 5/5, tampered 6/6, negatives 3/3. Print, scan, photo and WhatsApp steps are simulated, not real; corpus/CAPTURE.md is the procedure for real ones and npm run corpus -- --dir judges them by the same expectations |
#74 |
| Tests | 🟡 | cd backend && npm test (349, real MongoDB in memory), cd blockchain && npx hardhat test (42, real EVM), cd frontend && npm test (97, jsdom). Also driven by hand in a real browser against a local chain; not tested against real S3, Atlas, Polygon or a real wallet extension |
— |
Known limitation — what is proven. The Tier 2 and Tier 3 results come from simulated damage to one synthetic certificate (compression, resizing, rotation, noise, a drawn perspective). That is evidence the method works, not proof it works on photographs from real phones. The confidence threshold and Tier 3 distances must be re-measured on real captures before anyone relies on them (npm run robustness, npm run calibrate-tier3 in backend/).
Known limitation — perceptual hashing. Documents sharing a template produce similar perceptual hashes regardless of holder, and edits to printed text are invisible to it. Measured on the simulated set: a different person's card and a changed date of birth both read as consistent. Only a swapped photo is flagged. This is why Tier 3 is advisory only and can never approve a document on its own (see backend/TIER3.md).
| Layer | Technology |
|---|---|
| Frontend | React 18, Vite 7, Tailwind CSS 3 |
| Backend | Node.js, Express 5 |
| Database | MongoDB Atlas (AWS ap-south-1) |
| Object storage | AWS S3 (ap-south-1) |
| Blockchain | Polygon Amoy testnet (chain 80002) |
| Web3 | Ethers v6 |
| Contract | Solidity (Hardhat project in blockchain/) |
| Hashing | SHA-256; DCT perceptual hash |
| Document reading | Tesseract.js (local OCR, English data committed in backend/ocr-data/), sharp |
Testnet notice. Amoy is a test network with no economic security, and testnets are periodically deprecated. This is a demonstration deployment — it should not be described as immutable production infrastructure. A production deployment would target Polygon mainnet or a permissioned institutional chain.
Node.js LTS · npm · a MongoDB Atlas account · an AWS account with an S3 bucket · a funded Polygon Amoy wallet
git clone https://github.com/jpsiddharth2008/MindForge-Tatva-Finals.git
cd MindForge-Tatva-FinalsBackend
cd backend
npm install
cp .env.example .env # then fill it in — see below
npm run dev
npm test # needs no cloud account (a real MongoDB runs in memory)
npm run corpus # the forgery corpus: a verdict per file and a confusion matrix (see corpus/README.md)Frontend (separate terminal)
cd frontend
npm install
cp .env.example .env
npm run dev| Service | URL |
|---|---|
| Frontend | http://localhost:5173 |
| API | http://localhost:5000 |
| Health | http://localhost:5000/api/health |
backend/.env.example is the authoritative list with comments. The names the code actually reads:
PORT=5000
CORS_ORIGINS=http://localhost:5173 # exact origins; required in production
MAX_FILE_SIZE_MB=10
RATE_LIMIT_PER_MIN= # default 30 on /api/hash and /api/anchor
TRUST_PROXY= # proxy hop count, if behind a load balancer
MONGODB_URI= # without it the server runs but records nothing
AUDIT_RETENTION_DAYS=365
BUCKET_NAME=
REGION=ap-south-1
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
S3_SSE=AES256 # or aws:kms (+ S3_KMS_KEY_ID)
PRESIGN_TTL_SECONDS=300 # signed links; 300 is the maximum
RPC_URL=https://rpc-amoy.polygon.technology
CHAIN_ID=80002
CONTRACT_ADDRESS=
JWT_SECRET= # 32+ characters; the server refuses to start without it
ADMIN_USERNAME=
ADMIN_PASSWORD_HASH= # bcrypt hash: echo "pw" | npm run hash-password --silentThe backend does not hold a signing key today (the officer's wallet signs). blockchain/.env.example holds the deployer key used only by Hardhat scripts.
VITE_API_URL=http://localhost:5000 # the API origin, without a path
VITE_CONTRACT_ADDRESS= # the deployed CredentialRegistry (printed by the deploy script)
VITE_CHAIN_ID= # e.g. 80002 for Polygon Amoy. Without these the app can verify but not issue or revoke
⚠️ Vite inlines everyVITE_*variable into the client bundle. They are shipped to every visitor's browser and must be treated as public. AVITE_PRIVATE_KEYorVITE_MONGODB_URIis equivalent to publishing it. Database URIs, cloud secrets, and private keys belong exclusively inbackend/.env.
Both .env files are gitignored. Never commit either.
All routes are under the API origin. "Auth" means Authorization: Bearer <token> from /api/auth/login. Errors are { "success": false, "error": "…", "requestId": "…" }; the request id also appears in the X-Request-Id header and the server log.
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
GET |
/api/health |
— | Component states (never credentials or addresses) |
POST |
/api/auth/login |
— | Exchange the issuer's credentials for a token |
POST |
/api/hash |
— | Hash a file in memory, store nothing; records an audit event |
POST |
/api/anchor |
✅ | Store a document and create its record. Optional JSON fields form field adds the Tier 2 hash (images are read back and must agree: 422 if they confidently do not) |
POST |
/api/verify |
optional | Public. Multipart file (+ optional qr text). Returns the verdict, per-tier evidence and the anchor. Signed-in issuers also see registered values of changed fields. Rate-limited |
GET |
/api/documents |
✅ | Your documents, newest first. Query: status, limit (1-50), cursor. Returns documents, counts per status, nextCursor |
GET |
/api/documents/:id |
✅ | Document record |
GET |
/api/documents/by-hash/:sha256 |
✅ | Record by byte hash |
GET |
/api/documents/by-tx/:txHash |
✅ | Record by transaction hash |
POST |
/api/documents/:id/chain-pending |
✅ | The wallet sent a transaction |
POST |
/api/documents/:id/chain-confirmed |
✅ | Server checks the transaction on chain, then marks the document issued |
POST |
/api/documents/:id/chain-failed |
✅ | The wallet rejected or failed the transaction |
GET |
/api/documents/:id/qr |
✅ | The text to encode as this issued document's QR code |
POST |
/api/documents/:id/revoke |
✅ | After the wallet sent revoke(): the server reads the chain, then marks the document REVOKED (reason and time come from the chain) |
GET |
/api/documents/:id/audit |
✅ | History of one of your documents |
GET |
/api/audit |
✅ | Recent audit events (filters: action, outcome, limit, before) |
Not built: logout and "current user" endpoints (a session is an in-memory token that ends when the tab closes or after 8 hours).
| Control | Approach |
|---|---|
| Secrets | Environment variables only, validated at startup, never committed |
| Authentication | bcrypt password hash; signed JWT (HS256, 8-hour lifetime) sent as a Bearer token and kept in browser memory only (not in storage or cookies); login attempts are rate-limited |
| Authorisation | Issuing and record lookups need a valid token; one officer cannot read another's records; verification is public |
| On-chain authorisation | In the new contract, anchor() reverts unless the caller is a registered issuer. Not yet in force: the contract is not deployed and the frontend still calls the old, ungated one |
| Transport | HTTPS in production |
| At rest | S3 server-side encryption on every write (AES-256, or KMS if configured). MongoDB Atlas encrypts at rest by default; that is a service feature, not something this code configures |
| Object access | Signed URLs of at most 5 minutes; the code never builds a public bucket URL. The bucket's own Block Public Access setting must be checked with npm run check-bucket |
| Upload validation | MIME allowlist, magic-byte check, size cap |
| HTTP hardening | Helmet, environment-driven CORS allowlist, rate limiting, body size limits |
| Error handling | Generic client responses with a correlation ID; detail stays server-side |
| Logging | Structured logs with secret redaction |
Private keys · database URIs · cloud credentials · JWTs · passwords · request bodies on upload routes.
Logging a full error object from Mongoose or the AWS SDK can print the connection string, credentials included. Log
err.message, nevererr.
This project targets India's Digital Personal Data Protection Act, 2023, not GDPR.
| Requirement | Approach |
|---|---|
| Purpose limitation (§4–6) | Verification hashes in memory and discards the file |
| Data minimisation (§8) | Only the hash is anchored; no personal data on-chain |
| Right to erasure (§12) | Not implemented: there is no deletion endpoint yet. The audit trail expires on its own (default 365 days) and stores no document content or hashes |
| Security safeguards (§8(5)) | S3 encryption at rest, access control, audit logging; transport security depends on deployment (HTTPS) |
| Data localisation | Intended: AWS ap-south-1 (Mumbai). Set by how the bucket and cluster are created; the code does not enforce it |
On erasure and the blockchain. The on-chain hash cannot be deleted. Deleting the document and all metadata would leave a hash with no stored link to an individual, but the hashes are not salted today, so this is a weaker position than it sounds: an unsalted hash of a document in a known format is open to confirmation attacks, where someone holding a candidate document can check their guess against the chain. Salting is not implemented and is an open design question (a per-document salt would have to be kept to allow verification, which brings the same erasure problem back). We do not claim to have solved the blockchain-privacy tension.
querySrv ECONNREFUSED connecting to MongoDB
The network is blocking DNS SRV lookups — common on campus Wi-Fi. In Atlas → Connect → Drivers, select Node.js 6.7 or later and toggle off the SRV switch to obtain the long-form mongodb:// multi-host string.
MODULE_NOT_FOUND running the server
Run from the backend/ directory, not the repository root, and install dependencies first.
Blockchain initialisation fails
Check RPC_URL is reachable, that its chain matches CHAIN_ID, and that CONTRACT_ADDRESS has deployed bytecode on that chain. GET /api/health reports which of these is down without revealing the address or key. For deploying the contract, the deployer wallet needs enough POL for gas.
Frontend cannot reach the backend
Confirm VITE_API_URL and that CORS_ORIGINS in backend/.env includes the frontend origin. A request from an origin that is not listed is refused with 403. Restart Vite after changing .env — variables are read at build time.
Upload rejected
Only real PDF, PNG and JPEG files are accepted (the content is checked, not just the extension or declared type) and the size must be under MAX_FILE_SIZE_MB. The response says which check failed.
The server will not start
It exits with a message naming the missing setting (never its value): JWT_SECRET (32+ characters), ADMIN_USERNAME and ADMIN_PASSWORD_HASH are required.
Next Deploy the issuer-gated contract to a public network and verify its source (#62) · measure Tier 2 and Tier 3 on real phone captures and a proper forgery corpus (#52, #65, #74) · retire the duplicate Tier 2 implementation · more than one template
Later Institutional SSO and per-user roles · salting or another answer to the erasure question · deletion endpoint · batch issuance · W3C Verifiable Credentials and DID interoperability · selective disclosure via zero-knowledge proofs · mobile verification app
No license file is currently present. Add one before treating this project as open source.