Post-inference invisible-Unicode text watermarking for AI-content provenance (EU AI Act Art. 50)
Project description
glyphmark — post-inference text watermarking via hidden Unicode
glyphmark embeds an invisible, keyed, tamper-evident signature into text
after a model has produced it. The visible text is byte-for-byte unchanged,
but the result now carries:
- the request time (minute-resolution, UTC), and
- a robust "this was AI-generated" proof — an HMAC tag that only the holder of the secret key can mint, so it is detectable after the fact and hard to reproduce, and
- an optional literal message (e.g. "this has been text watermarked by Tanner").
It is designed to survive copy/paste, reformatting, splicing, rearrangement and deletion, and to be detectable inside any ~100-word window. This allows adding loose provenance signals to text when model inference is not available.
Pros
- Works with any length string.
- Durable under typical editing (but not against removal efforts).
- Extremely low probability of false positives.
- Can embed multiple marks showing provenance across different providers.
Cons
- Any stripping of special characters removes the signal (possibly a pro from a user-choice perspective, but a con from a surety perspective).
- Character count balloons to either accomplish (verbose messaging, resilient cryptography, etc. increase the character counts).
- Doesn't work for every text editor (e.g. special characters show up on Mac Core Text, etc.).
- Embedding a message does not itself prove the truth of the message.
Install
pip install glyphmark
The only runtime dependency is cryptography (for Ed25519). HMAC mode is
stdlib-only.
Use as a library
from glyphmark import Watermarker
import os
# Configure once with YOUR secret key, then mark/verify many times.
wm = Watermarker(key=os.environ["GLYPHMARK_KEY"].encode())
marked = wm.embed(model_output, message="generated by Acme LLM").text # send this on
result = wm.detect(some_text) # -> DetectResult
if result.detected:
print(result.timestamp_iso, result.message)
Public-key provenance — a third party can verify but not forge (the right choice if you expose a detector):
signer = Watermarker.ed25519() # signer holds the private key
marked = signer.embed(text).text
pub = signer.ed_public # publish this (32 bytes)
verifier = Watermarker.verifier(pub) # holds only the public key
verifier.detect(marked).detected # True
One-shot functional API is also available: from glyphmark import embed, detect.
Marking already-marked text (idempotency + multiple marks)
embed() is not silently idempotent — by default it won't double-mark.
on_existing controls the behavior when the text already carries a mark
verifiable under your key:
glyphmark.is_marked(text, key=...) # bool
wm.embed(text, on_existing="skip") # default: if already marked by this key, no-op
wm.embed(text, on_existing="replace") # strip ALL hidden chars, then mark afresh (one clean layer)
wm.embed(text, on_existing="add") # deliberately layer another mark (multi-party / multi-stage)
Every mark gets a random 2-byte mark id, so any number of independent marks can coexist and the detector reports each one separately:
res = detect(text, key=...)
res.num_marks # e.g. 3
for m in res.marks: # each: m.timestamp_iso, m.message, m.core_copies
...
This is also why two marks' messages never get mixed together.
Key management (read this for compliance use)
Provenance security rests entirely on key secrecy.
- HMAC mode — pass your own
key=(or setGLYPHMARK_KEY). If you pass nothing, the library falls back to a public demo key and emits a warning; watermarks made with it are forgeable. Never use it in production. - Ed25519 mode — keep the private seed secret; distribute only
ed_public. Anyone can verify with the public key; nobody can forge without the private one. This cleanly separates "anyone can check" from "only you can mint."
CLI
echo "The model wrote this." | glyphmark embed --message "by Acme" > out.txt
glyphmark detect -i out.txt
# without installing: python -m glyphmark embed ...
The four fields
| Watermarker | |
|---|---|
| Input text | the text to watermark |
| Output text | the watermarked text (visible chars unchanged) |
| Watermark message (optional) | literal string embedded in the hidden layer |
| Detector | |
|---|---|
| Input text | watermarked (possibly edited) text |
| Output text | the recovered signal: AI-generated proof + request time (+ message) |
How it works
Two independent layers:
1. Robust keyed packets (the real signal)
Payload bytes are encoded as invisible characters. There are two encodings:
- word-safe (default) — binary over two zero-width characters,
ZWNJ (
U+200C) = 0 and ZWJ (U+200D) = 1, one bit each (8 chars/byte), with WORD JOINER (U+2060) as decoy noise. Invisible in Microsoft Word, Google Docs, browsers and chat, and survives a Word copy/paste round-trip. We avoidU+200B(ZWSP) because Word silently strips it on paste, and variation selectors because they render as visible boxes (tofu) in Word/Docs. - compact (
--compact/ site toggle) — one variation selector per byte (U+FE00–FE0F,U+E0100–E01EF); ~4× denser, but shows as boxes in Word/Docs. Use only when the text will live in browsers/chat.
Packets are short (10 bytes) and self-contained, so a single surviving copy recovers everything. Each carries a 2-byte mark id so a mark's packets group together (enabling any number of coexisting marks):
CORE mark_id(2) || ts(3) || HMAC(key,"C"||mark_id||ts)[:5] provenance + time
MESSAGE mark_id(2) || idx(1) || total(1) || msg(2) || HMAC(key,"M"||…)[:4] one message chunk
- The HMAC tag is the provenance proof: forging a packet that validates is
~
2**-40per attempt without the key. Verifying it after the fact only needs the key. → detectable, hard to reproduce. - Packets are repeated throughout the text (a CORE packet roughly every 10–30 words depending on density), so any ~100-word window contains several.
- Each packet is XOR-whitened with a key-derived keystream, hiding any constant header/structure from someone inspecting the raw bytes.
- The detector slides a window over the invisible stream (10 bytes in compact, 80 zero-width bits in word-safe), keeps the windows whose MAC validates, and groups them by mark id — so it recovers every mark regardless of order, and a single intact packet suffices. This is why splicing/rearranging and block deletion barely dent it. (Word-safe packets are longer, so uniform per-character deletion hurts them more than compact — block edits are unaffected.)
2. Context-driven obfuscation (the confusion)
A large rule engine (rules.py) reads features of the
surrounding visible text — word-length parity, vowel/consonant balance,
digits, capitalization, punctuation, position mod N, keyed hashes of each word,
sentence index, and ~25 interacting rules — to decide where packets land
(keyed jitter), how many decoy characters to scatter, and which decoy
alphabet to draw from (variation selectors vs. a family of zero-width
characters). The decoys are key-derived, random-looking garbage that never
carries a valid MAC, so:
- an outside observer sees a complex, context-dependent mess that is hard to reverse-engineer or reproduce, while
- the legitimate detector simply ignores everything that does not validate.
Crucially, the obfuscation layer never touches the bytes of a real packet, so it adds confusion without costing robustness. This is mostly for fun.
Robustness
- Block deletion / splicing / rearrangement (deleting whole words, sentences, paragraphs; reordering) — the realistic editing case — recovers essentially 100% even when most of the document is removed, because surviving packets stay intact and order doesn't matter.
- Uniform per-character deletion (a harsh, less realistic channel) degrades gracefully: ~100% up to 20%, then falling off as packets get chopped. Higher density buys margin.
- Wrong key / no key → nothing validates (the provenance guarantee).
Run python make_figure.py to regenerate the plot, and pytest tests/ for the
round-trip + robustness test suite (20 tests).
Honest limitations
- Survival to copy/paste/reformatting depends on the destination preserving invisible code points. Most chat apps, docs and clipboards do; some sanitizers (e.g. systems that strip non-printing characters, or re-typing the text) will remove them — no invisible scheme can survive that.
- Provenance security rests entirely on key secrecy — keep
GLYPHMARK_KEYserver-side. The obfuscation layer is defense-in-depth, not a substitute. - Overhead is invisible but real: a few hundred invisible chars for a paragraph.
Provenance-only is cheapest; embedding a literal message multiplies it
(each 2-byte chunk is repeated). Use
--density low/--no-decoysto minimize.
Configuration
GLYPHMARK_KEY— secret HMAC key (env var). Use the same key to embed/detect.--density {low,medium,high}— redundancy vs. character count trade-off.--no-decoys— disable the garbage/obfuscation characters.
Try it
Live demo: https://textmark-65hh.onrender.com/ — paste text, watermark it, edit it, and detect it in your browser.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file glyphmark-0.2.0.tar.gz.
File metadata
- Download URL: glyphmark-0.2.0.tar.gz
- Upload date:
- Size: 28.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8b70dce0216753e0901fff37ff8908f676849b6fe026db6cc31ca1b091e38345
|
|
| MD5 |
227e92ace76fce98fb146520d77bc23c
|
|
| BLAKE2b-256 |
e07290a366cc7694b3707a484dfbc871cd73653840bc7113ee7a987ad61fa035
|
File details
Details for the file glyphmark-0.2.0-py3-none-any.whl.
File metadata
- Download URL: glyphmark-0.2.0-py3-none-any.whl
- Upload date:
- Size: 31.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
46fb3085f8c13e1d2a514ed10d6471275d599ba287150f240e857bd0d746d04b
|
|
| MD5 |
658d47f6ac8381e8c1b8d44ca562f3c4
|
|
| BLAKE2b-256 |
009d865ac1de41b7ffc4900ecb1b03fc1adcb8c845df0578e2808535e741aa8a
|