Skip to main content

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:

  1. the request time (minute-resolution, UTC), and
  2. 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
  3. 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 set GLYPHMARK_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 avoid U+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**-40 per 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_KEY server-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-decoys to 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

glyphmark-0.2.0.tar.gz (28.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

glyphmark-0.2.0-py3-none-any.whl (31.0 kB view details)

Uploaded Python 3

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

Hashes for glyphmark-0.2.0.tar.gz
Algorithm Hash digest
SHA256 8b70dce0216753e0901fff37ff8908f676849b6fe026db6cc31ca1b091e38345
MD5 227e92ace76fce98fb146520d77bc23c
BLAKE2b-256 e07290a366cc7694b3707a484dfbc871cd73653840bc7113ee7a987ad61fa035

See more details on using hashes here.

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

Hashes for glyphmark-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 46fb3085f8c13e1d2a514ed10d6471275d599ba287150f240e857bd0d746d04b
MD5 658d47f6ac8381e8c1b8d44ca562f3c4
BLAKE2b-256 009d865ac1de41b7ffc4900ecb1b03fc1adcb8c845df0578e2808535e741aa8a

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page