Skip to content

Repository files navigation

NullOrigin 🛡️

Universal AI Provenance & Watermark Sanitization Middleware Research artifact — for watermarking robustness evaluation only.

CI License: Apache 2.0 Python 3.10+ Tests Code style: ruff


🔬 Research use only

NullOrigin is a research artifact. It is published to support academic and independent study of watermarking robustness, and for no other purpose.

Watermarking schemes are security claims, and security claims are only meaningful once someone has tried to break them. The literature this project implements — Kirchenbauer et al. on KGW, Krishna et al. on paraphrase attacks, Boucher & Anderson on Trojan Source — exists because researchers published working attacks so that defenders could measure real robustness instead of assuming it. That is the tradition this repository belongs to.

Intended uses

  • Measuring how much a watermark's detectability degrades under a stated attack
  • Reproducing and extending published robustness results
  • Evaluating provenance metadata leakage in your own documents before you share them
  • Auditing source code for Trojan Source and homoglyph attacks (CVE-2021-42574, CVE-2021-42694)
  • Teaching how these schemes work, and where they hold up

Not intended, and not supported

  • Passing generated work off as your own where a person or institution has asked you not to
  • Defeating provenance signals on content you intend to misrepresent
  • Removing attribution from work that is not yours
  • Circumventing any platform, licence, or academic-integrity policy

Nothing here is a technical control on how the code runs. It is a statement of the terms on which it is offered, and of what its author will and will not support. The software is provided "AS IS", without warranty of any kind — see LICENSE.

Read Scope and honest limitations before drawing any conclusion from a number this tool prints. Several of the schemes it targets cannot be verified against a public detector, and the README says so rather than implying otherwise.


Contents


⚠️ Scope and honest limitations

Read this before drawing conclusions from any number this tool prints.

Layer What it actually does
Invisible characters Fully effective. Zero-width, bidi-control, variation-selector, and Unicode Tags-block payloads are removed completely, with a count reported. Cross-script homoglyph confusables (Cyrillic/Greek rendering as ASCII) are folded.
Document metadata (.docx) Fully effective. Author, last editor, revision count, timestamps, template, and application version are cleared from docProps, with formatting preserved byte-for-byte.
C2PA / EXIF / XMP Fully effective. The image is rebuilt from raw pixel samples into a fresh container, so signed JUMBF manifests and all metadata are gone. Verified by test against tagged fixtures.
KGW statistical watermark Depends entirely on the rewrite backend. With no local model configured, the statistical watermark survives — the tool says so rather than implying otherwise.
SynthID-Text / SynthID-Image / Tree-Ring Not verifiable here. These use private keys and proprietary decoders. NullOrigin applies the perturbations the literature describes, but no claim is made that they defeat the real detectors, because there is no public detector to measure against.
AudioSeal / SynthID-Audio Not verifiable here, for the same reason.

About the KGW detector

KGWStatisticalDetector is a mathematically faithful, self-consistent implementation of the Kirchenbauer et al. green/red-list scheme over whitespace tokens. It is not a decoder for any vendor's production watermark — those key on a private secret and the model's own BPE vocabulary.

Its purpose is to make the benchmark real: KGWWatermarkEmbedder plants a genuine watermark, the pipeline attacks it, and the matching detector measures the actual reduction. That is a true measurement of the attack against this scheme. It does not transfer to a vendor's watermark.


📖 Theoretical foundations

Text — KGW green/red list

The vocabulary $V$ is partitioned at each step $t$ by a hash seeded on the preceding context:

$$s_t = \text{Hash}(w_{t-k}, \dots, w_{t-1})$$

into a green list $G_t$ of size $\gamma|V|$ and a red list $R_t$. A bias $\delta > 0$ is added to green logits:

$$\tilde{l}_{t,v} = \begin{cases} l_{t,v} + \delta, & v \in G_t \\ l_{t,v}, & v \in R_t \end{cases}$$

Detection counts green hits. Under $H_0$ they are $\text{Binomial}(T, \gamma)$, so:

$$z = \frac{|S_G| - \gamma T}{\sqrt{T\gamma(1-\gamma)}}$$

with $z > 4.0$ ($p < 3\times10^{-5}$) flagged as synthetic.

Why paraphrasing attacks it: the watermark lives entirely in local n-gram transitions. Rewriting the surface form with an unwatermarked model reseeds every position. This is the standard robustness attack in the watermarking literature.

Why length matters: $z$ grows as $\sqrt{T}$. A 100-token passage at a 0.70 green fraction only reaches $z \approx 3.9$ — under threshold. Detection needs a few hundred tokens, and so do meaningful benchmark fixtures.

Image — C2PA and frequency-domain embedding

  • C2PA / JUMBF: signed manifests in JPEG APP11 segments, PNG tEXt/iTXt chunks, or WebP/AVIF c2pa boxes. Because the signature covers the pixel data, re-encoding from a bare sample buffer removes it without parsing JUMBF at all.
  • SynthID-Image: shifts in DCT/DWT coefficients. Attacked by soft-thresholding the high-frequency detail subbands.
  • Tree-Ring: a circularly symmetric pattern in the initial diffusion latent, surviving as coherent structure in the Fourier phase. Attacked by randomizing phase in mid-frequency annuli while preserving magnitude — which is why the image stays visually identical.

Audio — AudioSeal / SynthID-Audio

Sub-threshold phase modulation and low-amplitude spectral additions. Attacked by phase randomization above the speech fundamental, band-stop notch shifting in non-critical bands, and psychoacoustic re-quantization.


📦 Installation

Requirements

Python 3.10, 3.11, or 3.12
OS Linux, macOS (Intel and Apple Silicon), Windows via WSL2
Optional Ollama or any OpenAI-compatible server — required for text de-watermarking
Optional Docker 20.10+ with Compose v2

From source

git clone https://github.com/rakib-nyc/nullorigin.git
cd nullorigin

python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate

pip install -e .

Optional extras

pip install -e ".[dev]"       # pytest, pytest-asyncio, ruff  — needed to run the tests
pip install -e ".[nli]"       # torch + sentence-transformers, for the fidelity gate
pip install -e ".[metrics]"   # torch, transformers, sentence-transformers
pip install -e ".[llama]"     # llama-cpp-python for in-process GGUF inference
pip install -e ".[dev,metrics]"

Without [nli], the fidelity gate runs on invariants alone — still a real check, but blind to role swaps. See Semantic fidelity.

Verify the install

nullorigin --version
nullorigin --help
pytest -q                  # requires the [dev] extra

🚀 Quickstart

1. Set up a local rewrite model

Text de-watermarking needs an unwatermarked local model. Without one, NullOrigin removes invisible characters but leaves the statistical watermark intact — and says so.

ollama serve                    # in a separate terminal
ollama pull llama3.2:3b         # or any instruct model you prefer

Using a different model? Point NullOrigin at it:

export NULLORIGIN_PARAPHRASER_MODEL=qwen3:4b
export NULLORIGIN_PARAPHRASER_TIMEOUT=900     # reasoning models are slow

2. Start the proxy

nullorigin run
NullOrigin 1.0.0 — proxy listening on 127.0.0.1:8080
  providers:   anthropic, gemini, openai
  text engine: unicode=True backend=ollama
  media:       metadata=True stego=True
  telemetry:   open (loopback)
  health:      http://127.0.0.1:8080/health

3. Point your client at it

from openai import OpenAI

client = OpenAI(base_url="http://localhost:8080/v1", api_key="your-upstream-api-key")

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "Write an essay about privacy."}],
    extra_headers={"x-nullorigin-provider": "openai"},
)
print(response.choices[0].message.content)

Anthropic:

from anthropic import Anthropic

client = Anthropic(base_url="http://localhost:8080", api_key="your-upstream-api-key")
message = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Write an essay about privacy."}],
    extra_headers={"x-nullorigin-provider": "anthropic"},
)

curl:

curl http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "x-nullorigin-provider: openai" \
  -d '{"model":"gpt-4o","messages":[{"role":"user","content":"Hello"}]}'

Streaming (SSE) and Gemini (/v1beta/models/...) are handled the same way. The x-nullorigin-provider header selects the upstream and is stripped before forwarding; your auth headers pass through untouched.


🐳 Docker

git clone https://github.com/rakib-nyc/nullorigin.git
cd nullorigin

docker compose up -d
docker compose exec ollama ollama pull llama3.2:3b    # first run only
curl http://localhost:8080/health

The Compose stack runs NullOrigin plus an Ollama sidecar on a private bridge network. The proxy container binds 0.0.0.0 — correct inside a container — and only port 8080 is published to your host.

Standalone image:

docker build -t nullorigin:1.0.0 .
docker run -d -p 8080:8080 \
  -e NULLORIGIN_PARAPHRASER_BACKEND=none \
  nullorigin:1.0.0

Useful commands:

docker compose logs -f nullorigin
docker compose down          # stop
docker compose down -v       # stop and delete the Ollama model volume

🔒 Deploying beyond localhost

NullOrigin defaults to 127.0.0.1 and refuses to bind a public interface without a telemetry token. It relays your upstream API credentials, so this is deliberate:

$ nullorigin run --host 0.0.0.0
Error: Refusing to bind 0.0.0.0 without a telemetry token.

Choose one:
  - bind loopback:    nullorigin run --host 127.0.0.1
  - set a token:      export NULLORIGIN_TELEMETRY_TOKEN=$(openssl rand -hex 32)
  - accept the risk:  nullorigin run --host 0.0.0.0 --allow-public-bind

To expose it properly:

export NULLORIGIN_TELEMETRY_TOKEN=$(openssl rand -hex 32)
nullorigin run --host 0.0.0.0 --port 8080

then put it behind nginx, Caddy, or Traefik providing TLS termination, rate limiting, and an auth layer.

Threat model

NullOrigin is a local reverse proxy that relays your upstream API credentials. That single fact drives its security posture.

Control Default Why
Bind address 127.0.0.1 Loopback only; a public bind is refused unless a telemetry token is set or --allow-public-bind is passed.
/telemetry, /telemetry/reset Open on loopback Gated by X-NullOrigin-Token, constant-time compared, whenever proxy.telemetry_token is set.
/health Always open Container probes need it; exposes version and enabled engines, no secrets.
Request body size 100 MiB The proxy buffers bodies to forward them; larger input is rejected with 413.
SSE buffer 1 MiB An upstream that never terminates a frame is flushed, not buffered indefinitely.
Container user non-root The proxy needs no elevated privileges.

Known limitations, by design and not defects:

  • No TLS. It forwards Authorization and x-api-key verbatim over plain HTTP. Put it behind a reverse proxy that terminates HTTPS on any untrusted network.
  • No authentication on the proxy path. Anyone who can reach the port can proxy through it, using their own credentials — NullOrigin neither stores nor injects keys.
  • No rate limiting. Apply it at the reverse proxy.
  • Credentials are never persisted. No API key is written to disk or logs; telemetry counts requests and sanitization events only.
  • Upstream TLS verification stays on, and redirects are not followed.

To report a security issue, email imrakibul@gmail.com with [NullOrigin Security] in the subject.

Telemetry with a token set:

curl -H "X-NullOrigin-Token: $NULLORIGIN_TELEMETRY_TOKEN" http://localhost:8080/telemetry

/health is never gated, so container probes keep working.


🛠️ CLI

nullorigin run          [--host H] [--port P] [--config FILE] [--allow-public-bind]
nullorigin purge        INPUT -o OUTPUT [--verify] [--no-paraphrase] [--flatten-typography]
nullorigin inspect      INPUT [--json]
nullorigin benchmark    [--section text|media|audio] [-o report.json]
nullorigin build-datasets [--root DIR]
nullorigin test         [pytest args...]

Supported formats

Kind Extensions Notes
Images .png .jpg .jpeg .jfif .webp .tif .tiff .bmp .gif .ico .avif .jp2 All PIL modes (RGB, RGBA, L, LA, P, 1, I;16, CMYK, YCbCr). Animated GIF/WebP and multipage TIFF keep every frame and their timing. Images under 64px keep exact dimensions.
Audio .wav .wave 8/16/32-bit int, 32-bit float; mono through multichannel; any sample rate. Zero-length files round-trip.
Documents .docx .docm .dotx .pptx .pptm .xlsx .xlsm All three OOXML dialects. Text runs sanitized across body, headers, footers, footnotes, notes, and shared strings; docProps metadata cleared; every other part copied byte-for-byte.
PDF .pdf /Info dictionary, XMP packet, embedded attachments, and JavaScript removed; pages, text, and geometry preserved. Password-protected files are refused. See the caveat below.
Source code .py .js .ts .go .rs .java .c .cpp .rb .php .sh .sql + 50 more Trojan Source and homoglyph scanning. No NFKC, no paraphrasing — see below.
Text anything else decodable UTF-8, UTF-8 BOM, UTF-16, UTF-32, CP1252, Latin-1 — auto-detected and written back in the same encoding.

Everything else is refused with specific guidance rather than read as UTF-8 and corrupted — .mp3 points at ffmpeg -i in.mp3 out.wav, legacy .doc/.ppt/.xls at re-saving as OOXML. A refused file never produces output.

Verified against a 69-file corpus spanning every format above: 59 processed correctly, 10 refused cleanly, zero crashes, zero corrupted outputs.

Code is kept out of the rewriter

Assistant replies mix prose and code in one string. Handing the whole thing to a paraphrasing model rewrites the code along with the prose — and the z-score drops either way, so nothing downstream notices.

Responses are therefore segmented before anything is rewritten:

Segment Treatment
Prose Unicode-cleaned, then rewritten
Fenced blocks (``` and ~~~) Invisible and bidi characters stripped. No NFKC, never rewritten.
Inline `code` spans Same

This holds on the streaming path too, where a fence opens in one delta and closes several deltas later. A delta that straddles the boundary is split per line, so the closing ``` and the prose after it are treated differently. An unclosed fence fails safe: the remainder is protected rather than rewritten.

Disable with text.protect_code_blocks: false if you want the old behaviour.

Source code files: a security scan, not a watermark removal

There is no watermark in AI-generated source code. No provider watermarks code output, and no public detector exists. Anyone claiming to strip one is selling you something.

What source code does have is a real, published attack surface:

  • Trojan Source (CVE-2021-42574, Boucher & Anderson 2021) — bidirectional control characters reorder how code is displayed without changing how it compiles. A reviewer approves one program; the compiler builds another.
  • Homoglyph identifiers (CVE-2021-42694) — Cyrillic а for Latin a creates two names that render identically.
$ nullorigin purge auth.py -o auth_clean.py --verify
Scanning source file auth.py...
  bidi controls removed:   4
  invisible chars removed: 0

  TROJAN SOURCE DETECTED (CVE-2021-42574): 4 bidirectional control character(s).
  This file rendered differently than it compiled. Review the diff.

Findings:
  CRITICAL line 3:25  U+202E RIGHT-TO-LEFT OVERRIDE — reorders displayed text
      if access_level != "user‮ ⁦// Check if admin⁩⁦":

After purging, the line reads if access_level != "user // Check if admin": — the "comment" was inside the string all along.

Three things the code path deliberately does not do, because the generic text path did all three and each is a bug on source:

  1. No NFKC normalization. It rewrites string literal values: "Hello" became "Hello", "office" became "office". That changes what a program compares, hashes, and transmits.
  2. No paraphrasing. Sending code to a rewriting model produces code that does not run.
  3. Homoglyphs are reported, not folded. Folding a Cyrillic а merges two identifiers the compiler currently treats as distinct — silently changing behavior. Severity is MEDIUM only for mixed-script tokens (totаl), the actual attack signature; a word written entirely in another script is ordinary foreign text and scores INFO. Opt in with --fold-homoglyphs-in-code once you have reviewed them.

inspect --json emits per-finding severity, line, column, and codepoint, so it drops into CI as a pre-commit or PR gate.

PDF: what is and is not removed

Removed, verifiably: the /Info dictionary (Author, Title, Subject, Keywords, Creator, Producer, CreationDate, ModDate), the XMP packet in /Root/Metadata, embedded file attachments, and document-level JavaScript. Pages, text, and page geometry are preserved exactly; the operation is idempotent and byte-stable.

Detected but NOT removed: invisible characters inside page content streams. PDF draws text glyph by glyph through a font-specific encoding — a zero-width space in a CID-keyed font is a two-byte glyph index, not a literal U+200B — so a generic rewrite would corrupt the layout rather than clean it. inspect reports the count; purge prints a warning rather than staying silent, because silence would read as "there were none". To remove them, extract the text, run nullorigin purge on it, and regenerate the PDF.

--strip-annotations is available but off by default: annotations include links and form fields, not only comments, so removing them changes how the document behaves.

Typography is not a watermark

Em dashes, curly quotes, and ellipses are ordinary word-processor output. NullOrigin preserves them by default and reports them separately from genuine findings, because flattening them degrades a document without sanitizing anything. Use --flatten-typography if you specifically want ASCII output.

Cross-script confusables are different — a Cyrillic о mid-word in English text has no legitimate use — and those are folded by default.

--verify reports before/after measurements rather than asserting success:

$ nullorigin purge article.txt -o clean.txt --verify
Cleaning text structure and token transitions in article.txt...
  removed 14 invisible characters, folded 3 homoglyphs
  applying semantic restructuring via ollama backend...
  restructuring complete
Saved clean text to clean.txt

Verification (KGW statistical detector):
  z-score before: +5.3021  (p=5.73e-08)
  z-score after:  +0.8874  (p=0.187)
  detected before/after (z>4.0): True -> False

If the backend is unreachable, that is reported as a warning on stderr — a silent fallback would look identical to a successful sanitization.


📊 Benchmarking

nullorigin build-datasets
nullorigin benchmark

Every value is measured on the spot: text is watermarked with KGWWatermarkEmbedder, run through the real pipeline, and re-scored with the matching detector. The runner exits non-zero when thresholds are not met and explains why.

Thresholds (from the project directive):

Metric Target
Post-sanitization z-score $\lvert z\rvert \le 1.5$
Semantic similarity $\ge 0.92$
Image SSIM $\ge 0.95$
Image PSNR $\ge 36$ dB

Measured results

Full text section over datasets/text/watermarked_kgw.json, rewritten through Ollama (qwen3:4b) on an M-series MacBook (~150 s per passage):

sample                  z_before   z_after   reduced   detected
kgw_000                    4.212    -0.065     4.277         no
kgw_001                    5.297     0.484     4.813         no
kgw_002                    6.120    -0.482     6.601         no
kgw_003                    4.711     1.271     3.440         no
kgw_004                    5.696     0.209     5.486         no
invisible_payload         -0.447     1.091    -1.538         no

mean z: 4.2647 -> 0.4182        max |z| after: 1.271   (target: <= 1.5)
still detected at z > 4.0: 0 of 5
invisible chars remaining: 0

pass_z_threshold: PASS   pass_no_detection: PASS   pass_unicode_purge: PASS
OVERALL: PASS (3/3)

Every watermarked sample crossed from detected to undetected. Note kgw_003 at z = 1.271 — under threshold but the closest to it, which is the honest shape of this attack: it is statistical, not a guarantee.

Media, measured across the image fixtures:

sample                      ssim   psnr_dB  meta_clear
c2pa_tagged.png           0.9950     46.84         yes
exif_tagged.jpg           0.9690     40.54         yes
clean_control.png         0.9951     46.90         yes

Both image thresholds pass (SSIM ≥ 0.95, PSNR ≥ 36 dB). Numbers will vary with the model, hardware, and passage.

Semantic fidelity: a z-score drop is not evidence of preserved meaning

A rewrite that changes a fact scores exactly like a faithful one on the watermark metric. The obvious check for this does not work, and neither does the less obvious one. Measured on six drift cases plus a faithful control:

case lexical_f1 embedding cosine bidirectional NLI
negation dropped 0.70 0.77 ✓ 0.000 ✓
number 5 → 50 0.82 0.81 ✓ 0.000 ✓
entity/role swap 0.81 0.985 ✗ 0.000 ✓
quantifier all → some 0.88 0.91 ✓ 0.000 ✓
hedge removed 0.27 0.953 ✗ 0.011 ✓
"must not" → "must" 0.83 0.947 ✗ 0.000 ✓
faithful rewrite 0.33 0.931 ✓ 0.998 ✓

Lexical overlap is inverted. Every meaning-destroying edit scored higher than the faithful rewrite, because a good paraphrase shares few n-grams with its source while a corrupted one shares nearly all of them.

Embedding cosine does not fix it. Three of six corrupted cases pass a 0.92 threshold. "Alice paid Bob" and "Bob paid Alice" are the same bag of words and score 0.985; "must not disable""must disable" scores 0.947. Sentence embeddings encode topical relatedness, not truth.

So fidelity is checked in two layers, and neither is cosine:

  1. Invariants — numbers, negation count, modal strength, quantifier strength, URLs, emails, identifiers, named entities. Deterministic, dependency-free, and explainable (negation count changed: 1 → 0). Modals and quantifiers are compared by meaning class, so may → might passes and may → must fails. Blind to role swaps where every entity survives.
  2. Bidirectional entailment — meaning is preserved iff each text entails the other. Catches what counting cannot, including role swaps, and the asymmetry is diagnostic (hedge removal scored a⇒b 0.991, b⇒a 0.011). Requires nullorigin[nli]; without it the limitation is reported, not hidden.

It is a gate, not a report

Measuring drift after the fact does not help if the damaged text has already been returned. A failed check retries at a lower temperature — drift is temperature-driven — and after the retry budget returns the original, with ok=False and the reason.

text:
  fidelity:
    enabled: true
    max_retries: 2
    temperature_step: 0.25
    use_nli: true
    nli_threshold: 0.5

This also means the attack and the risk share one dial: turning up temperature lowers the z-score and raises the drift rate. The benchmark reports them together rather than as independent checks.

Other metrics

  • Perplexity — true GPT-2 PPL with torch + transformers, otherwise unigram_entropy_proxy, flagged approximate and not comparable to published PPL.
  • Cosine similarity is still reported as mean_cosine_or_lexical, for reference only. It is no longer a pass/fail gate, for the reasons in the table above.

⚙️ Configuration

Resolution order, lowest to highest precedence:

  1. Built-in defaults
  2. nullorigin.yaml (searched in ./, ../, /app/, or $NULLORIGIN_CONFIG)
  3. NULLORIGIN_* environment variables
  4. Explicit CLI flags

Key settings

Setting Default Notes
proxy.host 127.0.0.1 Loopback. A public bind is refused without a telemetry token.
proxy.port 8080
proxy.default_provider openai Used when no x-nullorigin-provider header is sent.
proxy.telemetry_token "" Guards /telemetry and /telemetry/reset.
proxy.max_request_bytes 104857600 100 MiB; larger bodies get 413.
text.paraphraser.backend ollama none | ollama | openai_compatible | llama_cpp | lexical. none leaves the statistical watermark intact. lexical needs no model but is a far weaker attack.
text.clean_unicode true Zero-width and Tags-block removal.
text.fold_homoglyphs true Cyrillic/Greek confusables to ASCII.
text.stream_window_tokens 40 Deltas buffered before a streaming span is rewritten.
media.crop_mode trim trim shifts coordinates without resampling; resample restores exact dimensions but costs roughly SSIM 0.81 / PSNR 31 dB even at a 0.5% crop; none disables the geometric pass.
audio.low_cut_hz 800.0 Phase below this is preserved for intelligibility.

Environment variables

NULLORIGIN_CONFIG                  # path to nullorigin.yaml
NULLORIGIN_HOST                    # bind address
NULLORIGIN_PORT
NULLORIGIN_TELEMETRY_TOKEN
NULLORIGIN_MAX_REQUEST_BYTES
NULLORIGIN_DEFAULT_PROVIDER
NULLORIGIN_PARAPHRASER_BACKEND     # none | ollama | openai_compatible | llama_cpp | lexical
NULLORIGIN_PARAPHRASER_ENDPOINT    # alias: NULLORIGIN_OLLAMA_ENDPOINT
NULLORIGIN_PARAPHRASER_MODEL
NULLORIGIN_PARAPHRASER_MODEL_PATH  # llama_cpp GGUF path
NULLORIGIN_PARAPHRASER_API_KEY
NULLORIGIN_PARAPHRASER_TIMEOUT
NULLORIGIN_PARAPHRASER_TEMPERATURE
NULLORIGIN_CLEAN_UNICODE
NULLORIGIN_FOLD_HOMOGLYPHS
NULLORIGIN_PURGE_METADATA
NULLORIGIN_DISRUPT_STEGO
NULLORIGIN_DISRUPT_AUDIO

🩺 Troubleshooting

model 'llama3.2:3b' not found The configured model is not pulled. Run ollama list to see what you have, then either ollama pull llama3.2:3b or set NULLORIGIN_PARAPHRASER_MODEL to a model you already have.

WARNING: ollama backend unavailable (ReadTimeout) The rewrite exceeded text.paraphraser.timeout_seconds (default 120 s). Reasoning models such as qwen3 routinely take 150 s+ per paragraph on CPU. Raise it: export NULLORIGIN_PARAPHRASER_TIMEOUT=900, or use a smaller instruct model.

nullorigin benchmark exits 1 with pass_no_detection: FAIL Working as intended. No rewrite backend was reachable, so only the unicode layer ran and the statistical watermark survived. Start Ollama, or set the backend to lexical for a dependency-free comparison.

semantic_check: INCONCLUSIVE Expected without the [metrics] extra. See Metric honesty.

Error: Refusing to bind 0.0.0.0 without a telemetry token Intentional. See Deploying beyond localhost.

Multiple top-level packages discovered in a flat-layout You are on an old checkout. pyproject.toml sets an explicit package list; pull latest.

Async tests report UsageError about a missing async plugin Deliberate — without one, pytest reports async def tests as passing without awaiting them. pip install -e ".[dev]".

Docker: curl: (7) Failed to connect right after compose up The healthcheck has a 10 s start period. Wait, then check docker compose logs nullorigin.


🏗️ Architecture

                    Client / Application
                            |
              [http://localhost:8080/v1/...]
                            v
        +===================================================+
        |               NULLORIGIN CORE PROXY               |
        |  HTTP/SSE interceptor · provider schema adapter   |
        |  /health · /telemetry · transparent auth passthru |
        +===================================================+
                            |
              [request forwarded unmodified]
                            v
              Upstream Provider API (Anthropic / OpenAI / Gemini)
                            |
                  [watermarked payload]
                            v
        +===================================================+
        |            SANITIZATION PIPELINE ROUTER           |
        +===================================================+
           /                 |                      \
   (text/JSON+SSE)      (image/*)                (audio/wav)
          v                  v                        v
  +----------------+  +------------------+  +------------------+
  | MODULE B: TEXT |  | MODULE C: MEDIA  |  | MODULE D: AUDIO  |
  | unicode purge  |  | C2PA/EXIF scrub  |  | phase randomize  |
  | homoglyph fold |  | DWT threshold    |  | notch shifting   |
  | KGW detector   |  | Fourier phase    |  | psychoacoustic   |
  | SLM rewriter   |  | dither           |  | requantization   |
  +----------------+  +------------------+  +------------------+
           \                 |                      /
            +----------------+---------------------+
                            v
              Schema reconstruction (SSE framing preserved)
                            v
                    Sanitized stream / file

Layout

nullorigin/
├── cli.py                        # run, purge, inspect, benchmark, build-datasets, test
├── config.py                     # Pydantic v2 settings + env overrides
├── proxy/
│   ├── server.py                 # FastAPI reverse proxy, /health, /telemetry
│   ├── interceptors.py           # SSE frame parser + sliding-window rewriter
│   ├── telemetry.py              # thread-safe runtime counters
│   └── schemas.py                # provider request/response models
├── engines/
│   ├── text/
│   │   ├── unicode_cleaner.py    # invisible chars, Tags block, homoglyphs
│   │   ├── paraphraser.py        # pluggable rewrite backends
│   │   └── kgw_detector.py       # detector + Viterbi embedder
│   ├── media/
│   │   ├── c2pa_remover.py       # JUMBF/EXIF/XMP stripping + inspection
│   │   └── stego_breaker.py      # DWT thresholding, Fourier phase, dither
│   └── audio/
│       └── audio_cleaner.py      # phase randomization, notch shifting
└── evaluation/
    ├── metrics.py                # SSIM, PSNR, PPL, semantic similarity
    ├── datasets.py               # deterministic fixture generation
    └── runner.py                 # measured benchmark harness

🧪 Testing

pytest -q

384 tests. The suite covers the SSE frame parser against adversarial chunk boundaries, proxy streaming lifecycle, every PIL image mode, SSIM against both a closed-form value and a brute-force reference implementation, and dataset watermark detectability.

Async tests fail loudly if no async plugin is installed rather than silently skipping.


📖 Citing and reuse

Licensed under Apache-2.0, which permits use, modification, and redistribution provided the copyright notice and attribution to Muhammad Rakibul Islam are retained. See LICENSE and NOTICE.

If this work supports a publication, please cite it as:

@software{islam_nullorigin_2026,
  author  = {Islam, Muhammad Rakibul},
  title   = {{NullOrigin}: Universal AI Provenance and Watermark
             Sanitization Middleware},
  year    = {2026},
  version = {1.2.0},
  url     = {https://github.com/rakib-nyc/nullorigin},
  note    = {Research artifact for watermarking robustness evaluation}
}

This repository is published as a finished research artifact and is not accepting pull requests. You are free to fork it under the terms of the licence. Questions and findings are welcome by email at imrakibul@gmail.com.


🗺️ Project status

Version 1.0.0. The suite is complete against its specification and fully tested, with these known boundaries:

  • Effectiveness against SynthID, Tree-Ring, and AudioSeal is unverifiable here — no public detector exists to measure it. The perturbations are implemented; the claim is not made.
  • Semantic drift is gated by invariants plus optional NLI. Without [nli], role swaps that preserve every entity are undetectable, and the report says so.
  • Latency with a local reasoning model (~150 s per passage) makes the text path unsuitable for interactive use. A smaller instruct model is much faster.
  • No TLS and no request authentication — by design; run it behind a reverse proxy.

See CHANGELOG.md for release history.


📚 References

  • Kirchenbauer et al., A Watermark for Large Language Models (2023) — the KGW scheme
  • Dathathri et al., Scalable watermarking for identifying large language model outputs (Nature, 2024) — SynthID-Text
  • Wen et al., Tree-Rings Watermarks: Invisible Fingerprints for Diffusion Images (2023)
  • San Roman et al., Proactive Detection of Voice Cloning with Localized Watermarking (2024) — AudioSeal
  • Krishna et al., Paraphrasing evades detectors of AI-generated text (2023)
  • Wang et al., Image Quality Assessment: From Error Visibility to Structural Similarity (2004) — SSIM
  • C2PA Specification

📜 Legal notice & attribution

  • Author & Creator: Muhammad Rakibul Islam
  • Contact: imrakibul@gmail.com
  • License: Apache License, Version 2.0

This repository is a research-grade artifact released for statistical research, privacy evaluation, watermarking robustness benchmarking, and cryptographic resilience testing.

Copyright 2026 Muhammad Rakibul Islam <imrakibul@gmail.com>

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

NO WARRANTIES. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED.

About

Research-only AI watermark & provenance robustness toolkit: local reverse proxy (OpenAI/Anthropic/Gemini) + CLI stripping C2PA/EXIF/XMP, zero-width & homoglyph Unicode, KGW text watermarks, DWT/Tree-Ring image stego, AudioSeal, PDF/DOCX/PPTX/XLSX metadata, and Trojan Source (CVE-2021-42574) code scanning.

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages