Metadata-Version: 2.4
Name: glyphmark
Version: 0.2.0
Summary: Post-inference invisible-Unicode text watermarking for AI-content provenance (EU AI Act Art. 50)
Project-URL: Homepage, https://textmark-65hh.onrender.com/
Author-email: Tanner Mathison <tanner.mathison@gmail.com>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: ai-generated,compliance,ed25519,eu-ai-act,hmac,provenance,steganography,unicode,watermark,zero-width
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Legal Industry
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security :: Cryptography
Classifier: Topic :: Text Processing :: General
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: cryptography>=42.0
Provides-Extra: dev
Requires-Dist: build; extra == 'dev'
Requires-Dist: matplotlib>=3.8; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: twine; extra == 'dev'
Provides-Extra: web
Requires-Dist: flask>=3.0; extra == 'web'
Requires-Dist: gunicorn>=21.0; extra == 'web'
Requires-Dist: waitress>=3.0; extra == 'web'
Description-Content-Type: text/markdown

# 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

```bash
pip install glyphmark
```

The only runtime dependency is `cryptography` (for Ed25519). HMAC mode is
stdlib-only.

## Use as a library

```python
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):

```python
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:

```python
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:

```python
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

```bash
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.
