Chain of custody for AI-generated media. Generate through a multi-provider Genblaze pipeline, seal a SHA-256 provenance manifest inside the file itself, store it immutably on Backblaze B2 — then prove, byte for byte, that it hasn't been touched.
Built for the Backblaze Generative AI Media Hackathon on Genblaze + Backblaze B2.
Since 2 August 2026, EU AI Act Article 50 requires providers of generative AI systems to mark synthetic audio, image, video and text in a machine-readable format, detectable as artificially generated.
Almost every generative media stack in production today fails this. A model returns a URL, someone downloads a PNG, it moves through Slack, a CMS and three re-exports, and within a day nobody can answer the two questions that matter:
- What produced this? Which model, which prompt, which parameters, when?
- Is this still what was produced? Or has it been edited since?
Newsrooms, agencies, stock marketplaces and compliance teams are being asked those questions now, and "check our internal spreadsheet" is not an answer that survives a dispute.
ProofPrint makes the proof part of the asset and the reference copy immutable.
brief ──► Gemini chat ──► NVIDIA NIM (FLUX.1) ──► manifest ──► embed in file ──► B2 Object Lock
expand prompt ├ fallback: SD 3.5 Turbo SHA-256 PNG iTXt GOVERNANCE
├ fallback: SDXL canonical JPEG XMP + ledger record
└ failover: Google Gemini hash MP4 uuid box + content-addressed
Verification then answers three independent questions, because a single boolean can't distinguish the ways provenance breaks:
| Layer | Question | Mechanism |
|---|---|---|
| 1 | Is a manifest present? | handler.extract() on the file's own bytes |
| 2 | Is the record internally consistent? | Manifest.verify_hash() — editing the embedded prompt/model/timestamp breaks the canonical hash |
| 3 | Do the bytes still match what we sealed? | Re-hash the upload, compare against the Object-Locked reference digest in B2 |
Layer 3 is what makes this more than self-attestation. The reference hash is written to an immutable B2 object before the file ever leaves the pipeline, so forging a pass requires altering a record that cannot be altered.
Verdicts: AUTHENTIC · MODIFIED (genuine record, edited bytes) · TAMPERED (record itself
was altered) · UNSIGNED · UNKNOWN_ORIGIN · EXTERNAL_PROVENANCE.
A verifier that only understands its own files is not much of a verifier. Before any of the
three layers run, ProofPrint reads C2PA Content Credentials — the industry provenance
standard behind Adobe, Microsoft, OpenAI, Google and C2PA-enabled Leica/Sony bodies. An image
from DALL·E, Firefly or Photoshop therefore returns EXTERNAL_PROVENANCE with its claim
generator, signing certificate, signing time and declared actions, including the
algorithmicMedia digital-source marker that denotes AI-generated content.
ProofPrint is a provenance system, not an AI detector. UNSIGNED means "no provenance
found" — never "this is not AI". An image with no Content Credentials and no Genblaze
manifest is simply unknowable; detecting synthetic media from pixels alone is an open research
problem, and C2PA has exactly the same boundary.
ProofPrint's own manifests are hash-protected, not PKI-signed. Layer 2 detects edits to the record, and layer 3 anchors the bytes to immutable storage, but unlike C2PA there is no certificate chain a third party can validate without trusting this deployment's B2 ledger. That is a deliberate scope choice for an internal chain-of-custody tool, not a claim of equivalence to C2PA signing.
B2 is not a dumping ground here — it is the evidence store, and four distinct B2 capabilities carry real weight:
1. Object Lock as the trust anchor. Sealed assets, manifests and ledger records are written
with ObjectLockConfig(mode="GOVERNANCE", retain_until=…) via Genblaze's ObjectStorageSink(manifest_lock=…)
and backend.put(object_lock=…). Ordinary deletes and overwrites fail. This is the property
that makes layer-3 verification meaningful — the ledger record holding the reference digest is
precisely what an attacker would need to rewrite, so it is locked too. GOVERNANCE (not
COMPLIANCE) keeps a demo bucket recoverable by a privileged key; retention is configurable via
B2_OBJECT_LOCK_DAYS.
Object Lock is settled with a single throwaway probe object at startup rather than assumed: a
bucket created without it, or an app key lacking writeFileRetentions, degrades to standard
writes with a loud log line instead of killing a mint half-way through. /api/health and every
ledger record report what was actually applied, never what was merely requested.
2. Content-addressable keying. KeyStrategy.CONTENT_ADDRESSABLE means the storage key is
the SHA-256 — the path itself is an integrity claim, and identical outputs across thousands of
generated variants collapse onto one object.
3. An append-only ledger, one immutable object per mint. Records live at
proofprint/ledger/{timestamp}_{run_id}.json rather than in one mutable index file, so
concurrent mints can never clobber each other and a prefix listing is a cheap chronological
scan. This is the app's only database — there is no Postgres.
4. B2 serves the bytes. /api/asset/{sha} issues a short-lived presigned URL and redirects.
Media is served by B2 directly, never proxied through the app.
Bucket layout:
proofprint/
runs/{tenant}/{date}/{run_id}/manifest.json Genblaze sink · Object Lock
assets/{sha[:2]}/{sha[2:4]}/{sha}.png content-addressed raw output
sealed/{sha256}.png manifest embedded in-file · Object Lock
ledger/{timestamp}_{run_id}.json append-only index
Genblaze is the orchestration and provenance engine, not a thin wrapper:
Pipelinebuilds each mint, withtenant_id/project_idfor multi-tenancy.fallback_modelsgives in-provider model fallback: FLUX.1-schnell → SD 3.5 Turbo → SDXL, handled inside Genblaze without the caller knowing.- Cross-provider failover on top: if the whole NVIDIA NIM leg is down, the mint re-runs against Google. Genblaze's uniform Pipeline API is exactly what makes that a provider swap instead of a rewrite. Every attempt — including failures and their latencies — is surfaced in the UI, because "it fell back twice and still delivered" is the real production story.
ObjectStorageSink+S3StorageBackend.for_backblazewithCONTENT_ADDRESSABLEkeying andmanifest_lockfor immutable manifests.- Provenance manifests — the core of the product.
manifest.canonical_hash,verify_hash(),verify()andverification_report()drive the verification verdicts. genblaze_core.mediahandlers (PngHandler,JpegHandler,WebpHandler,Mp4Handler,get_handler,sniff_mime) embed and extract the manifest in-file. This is the machine-readable marking Article 50 asks for.parent_run_id(set the wayPipeline.from_result()sets it) gives iteration lineage — "Iterate on this" links a new run to its ancestor, and certificates render the full v1 → v2 → v3 chain plus direct children.genblaze_google.chatexpands a short human brief into a production prompt, so the certificate shows both what the human asked for and what the model was actually given.
| Role | Provider | Models |
|---|---|---|
| Prompt expansion | Google (genblaze-google) |
gemini-2.5-flash |
| Image generation (primary) | NVIDIA NIM (genblaze-nvidia) |
black-forest-labs/flux.1-schnell |
| Image fallback (in-provider) | NVIDIA NIM | stabilityai/stable-diffusion-3-5-large-turbo, stabilityai/stable-diffusion-xl |
| Image failover (cross-provider) | Google (genblaze-google) |
gemini-2.5-flash-image, gemini-3.1-flash-image |
| Storage | Backblaze B2 (genblaze-s3) |
S3-compatible, Object Lock |
Every image model in the primary path is open-weight (FLUX.1, Stable Diffusion 3.5, SDXL).
Requires Python 3.11+.
git clone <this-repo> && cd proofprint
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then fill in the values below
set -a && source .env && set +a
uvicorn app.main:app --reload --port 8000Open http://localhost:8000. GET /api/health reports exactly what is and isn't configured.
| Variable | Where |
|---|---|
B2_BUCKET, B2_KEY_ID, B2_APP_KEY |
Backblaze B2 → create bucket with Object Lock enabled, then App Keys |
NVIDIA_API_KEY |
build.nvidia.com → any model → Get API Key (free credits, no card) |
GEMINI_API_KEY |
aistudio.google.com/apikey (free tier) |
Object Lock must be enabled at bucket creation — B2 cannot turn it on afterwards. If your bucket doesn't have it, set
B2_OBJECT_LOCK_DAYS=0; everything works except the immutability guarantee.
The repo ships a Dockerfile and render.yaml.
- Push to GitHub.
- Render → New → Blueprint → point at the repo.
- Set the secret env vars (
B2_*,NVIDIA_API_KEY,GEMINI_API_KEY). Everything else has defaults.
Health check is wired to /api/health.
| Endpoint | Purpose |
|---|---|
GET /api/health |
Config transparency — providers, bucket, Object Lock state |
POST /api/mint |
{brief, expand, parent_run_id, project_id} → generate, seal, store, record |
POST /api/verify |
multipart file upload → three-layer verdict |
GET /api/ledger |
Newest-first archive, read from B2 |
GET /api/record/{sha256} |
One ledger record |
GET /api/lineage/{run_id} |
Ancestors + direct children |
GET /api/asset/{sha256} |
302 → short-lived presigned B2 URL |
Interactive docs at /docs.
- Studio → generate anything → Download sealed file.
- Verify → drop that file →
AUTHENTIC, with the full recovered manifest. - Open it in any editor, change one pixel, re-export, drop it again →
MODIFIED.
Built against genblaze==0.4.5 (core 0.3.8 / s3 0.3.6 / nvidia 0.3.3 / google 0.3.4). Two
things worth flagging for other builders:
- GitHub release tags don't map to PyPI versions. Tags
v0.5.x–v0.7.xexist on GitHub while0.4.5is the latest umbrella on PyPI, sopip install genblaze==0.7.0fails. Pin from PyPI, not from the release page. Pipeline.from_result()requires a livePipelineResult. For lineage across HTTP requests you only have the parent'srun_id, and the public API has no way to pass it —from_result()internally does nothing but set_parent_run_id. A publicparent_run_id=argument onPipeline(...)would make cross-process lineage a first-class operation.
MIT