VeritasChain turns any file into a tamper-proof record on Ethereum. It stores the file on IPFS, writes the file's fingerprint to the blockchain with your wallet and the exact time, and gives you a proof anyone can check.
Stamp a photo, document or dataset in one upload
Prove who published it and when, down to the block
Detect any edit: change one pixel and the proof fails
A photo, a contract, a dataset. Any bytes you want to prove.
The problem
Proving a file is real usually means trusting somebody.
Metadata can be rewritten and timestamps forged. The usual fix is a platform, a notary or a vendor who vouches for the bytes. That works until the vendor shuts down, gets acquired, or stops agreeing with you.
step 1 · pinContent becomes its own ID
The file goes to IPFS. Its CID is a hash of the bytes, so change one pixel and the CID changes completely.
step 2 · anchorThe chain records who and when
uploadFile(cid, name) stores the hash with msg.sender and block.timestamp.
step 3 · proveAnyone can check it
Share the CID and transaction ID. Verification is a read: no sign-up, no wallet, no API key.
⬢
The proof outlives the platform. If every server in this project goes dark, the record still stands on chain, readable by any Ethereum client.
What you get
Stamp from a browser or a pipeline.
A wallet is the only account. The same backend serves the web app and a keyed developer API.
VeritasChain proves that a file existed at a given moment and was published by a given wallet. The only thing you trust is a public blockchain.
You publish a file. The backend pins it to IPFS, and the resulting content hash is written to an Ethereum contract with your wallet address and the block timestamp. From then on, anyone can check that record without asking permission, creating an account, or relying on this project's servers.
Tamper-evident
The CID is a hash of the bytes. A one-bit change gives a different CID, so the old record no longer matches.
Independent
The record lives on chain. It stays readable if this project disappears.
No accounts
There is no user table. Your wallet address is your identity.
Not a privacy tool
IPFS is public and content-addressed. Anyone with a CID can fetch the bytes. Stamp only what you are willing to publish.
Quick start
Bring up the full local stack with one command, or set up each part by hand.
Docker
./start_all.sh # macOS / Linux
.\start_all.ps1 # Windows PowerShell
This starts Ganache and IPFS, migrates the contract, then boots the backend and frontend.
Service
URL
Frontend
http://localhost:3000
Backend API
http://localhost:8000
Swagger docs
http://localhost:8000/docs
IPFS WebUI
http://localhost:5001/webui
Ganache RPC
http://localhost:7545
Manual setup
You need Node.js 18+, Python 3.10+, MetaMask, and Docker or a local Ganache.
Smart contracts
Deploy with Truffle and note the contract address for the backend.
Backend
Create a virtualenv, install requirements, copy .env.example, run uvicorn.
Frontend
Install packages, copy .env.example, run npm start.
CORS_ORIGINS defaults to ["*"] in backend/app/core/config.py. Set it to your real origins before any public deployment.
Stamping a file
Connect MetaMask and drop a file. It becomes a permanent record.
POST /api/v1/files/upload
POST /api/v1/files/upload evidence.png · 0xA3f…91c
✦ pinned to IPFS QmX4f…8ab
✦ anchored on Ethereum uploadFile(QmX4f…8ab, "evidence.png")
✦ mapped transaction 0x7d2e…44f1
✓ certificate CID QmX4f…8ab · tx 0x7d2e…44f1
What happens
Upload
The frontend sends file, file_name and user_address to FastAPI.
Pin
The backend buffers the file to a temp path and calls IPFS /api/v0/add. The CID comes back and the temp file is deleted.
Anchor
uploadFile(cid, fileName) is sent from user_address. The contract stores the hash, name, msg.sender and block.timestamp.
Map
mapTransactionToIPFS(txHash, cid) links the transaction to the CID, so either one can find the other.
Certificate
The frontend shows the CID and transaction ID. Together they are the shareable proof.
The CID is the fingerprint. An identical file always gives the identical CID, and any single-bit change gives a different one.
Verifying a file
Verification is a read. It needs no wallet, no API key and no account.
verify
GET /api/v1/files/verify?file_hash=QmX4f…8ab
✓ AUTHENTIC content resolves on IPFS
GET /api/v1/files/verify?file_hash=QmZZZ…000
✗ NO RECORD not found
The backend calls IPFS /api/v0/ls/<CID>. If the content resolves, the response is { "file_exists": true }.
Scope of the check
/files/verify checks that the content exists on IPFS. The chain side, meaning who published it and when, is a separate read:
Question
Where to read it
Does the content exist?
GET /files/verify
Who published it, and when?
GET /files/transactions, GET /files/all-hashes
Straight from the contract
verifyFile(fileHash), getFileMetadata(...)
A full provenance check reads both the IPFS side and the chain side.
API keys
Stamp from CI, a cron job or a camera rig instead of a browser.
# 1. mint a key for your wallet
curl -X POST "http://localhost:8000/api/v1/api-keys/generate?user_address=0xYourWallet"
# 2. stamp a file
curl -X POST "http://localhost:8000/api/v1/files/developer/upload?user_address=0xYourWallet" \
-H "X-API-Key: <your-key>" \
-F "file=@evidence.png" -F "file_name=evidence.png" -F "user_address=0xYourWallet"
# 3. verify it (public, no key needed)
curl "http://localhost:8000/api/v1/files/verify?file_hash=<ipfs-cid>"
How a key is checked
Format
Keys look like cg-XXXXXXXXXXXXXXXX-20260315183728: 16 characters from secrets.choice, then a timestamp.
Lookup
An unknown key returns 401 Invalid API key.
Wallet binding
If the request names a user_address other than the key's wallet, it returns 403 Wallet mismatch.
Alpha. Stamp and verify work end to end on a local Ganache chain. The trust boundary around them is not finished.
State
Area
Detail
done
Contract storage and scoping
State keyed by msg.sender
done
IPFS pinning and resolution
Kubo /api/v0/add and /ls
done
Local dev stack
One-command Docker bring-up
gap
Wallet ownership
user_address is an unauthenticated request field
gap
API key issuance
POST /api-keys/generate mints a key for any address
limited
Transaction signing
.transact({"from": …}) needs node-held keys, so Ganache only
planned
Testnet and mainnet
Blocked on client-side signing
planned
Key storage
Flat JSON file today, with no rotation or hashing at rest
The open security gaps
Anyone can stamp a file as anyone else
POST /files/upload takes user_address as a plain form field. Nothing proves the caller controls that wallet, so a forged record can name any address.
API keys can be minted for any wallet
POST /api-keys/generate?user_address=0xVictim issues a working key with no ownership check, and GET /api-keys/get/{address} returns an existing key in plain text. The key-to-wallet check behind them is correct, but this endpoint bypasses it.
The planned fix
Sign a nonce
The client signs a server-issued nonce with MetaMask.
Recover the signer
The backend calls w3.eth.account.recover_message and trusts that address, not the supplied string.
Sign transactions client-side
This replaces .transact() and unblocks real networks.
Until this lands, treat VeritasChain as a local demonstration of the architecture, not a service for records you need to defend.
Philosophy
VeritasChain was built anonymously. No names, no faces, no VC funding.
The belief is simple: knowledge should be free, and the tools to verify it should be available to everyone.
⬢
"In a world where reality is manufactured, the only currency worth having is verifiable truth."
Judge the code, not the coder. Released under the MIT licence.