Skip to content

Repository files navigation

veilmark

Invisible, error-corrected, authenticated text watermarking for Python.

English | 简体中文

CI PyPI Python License: MIT

Hide a short message inside a document so that the document still reads exactly as it did before — then get the message back out even after a third of the text has been deleted, and know for certain whether what came back is authentic.

import veilmark

marked = veilmark.embed(document, "issued-to:alice", password="hunter2").text
# `marked` looks identical to `document` in any editor, browser or PDF.

veilmark.extract(marked, password="hunter2").watermark
# 'issued-to:alice'

Why another one

Most zero-width watermarking libraries do the same three things, and share the same three failure modes:

The usual approach What goes wrong
One contiguous run of zero-width characters Delete the paragraph it sits in and the mark is gone. Nothing degrades gracefully; it works or it doesn't.
XOR or a bare hash for "encryption" You cannot tell "no watermark here" from "wrong password" from "someone edited the payload". Every failure looks like garbage bytes.
U+FEFF or ZWJ as the carrier U+FEFF is a byte-order mark and gets eaten by half the tools in the pipeline. ZWJ changes how emoji and Arabic/Indic scripts render — it is not invisible, it is load-bearing.

veilmark fixes each one deliberately:

  • Damage tolerance. The payload is cut into shards, protected with a Reed–Solomon erasure code, and spread across the whole document. Any k of the k + m shards rebuild the message. At the highest setting you can delete two thirds of the text and still recover the mark.
  • Real cryptography. PBKDF2-SHA256 (200 000 rounds) into AES-256-GCM, with a fresh salt for every message. The AEAD tag makes no watermark, wrong password and tampered three distinguishable answers.
  • Semantically inert carriers. The default alphabet is the Unicode variation selectors — the most inert invisible characters available. They don't inhibit line breaking, don't affect script shaping, don't glue emoji into sequences, and can't be mistaken for a BOM.
  • Self-description. Only the password is ever needed to decode. The alphabet is identified from the characters present; every other parameter rides in a CRC-protected control frame that is replicated across the document.

Install

pip install veilmark

Python 3.10+. One dependency: cryptography.

Quick start

Library

import veilmark

document = open("report.md", encoding="utf-8").read()

# Embed. Everything except the mark itself is optional.
result = veilmark.embed(document, "issued-to:alice", password="hunter2")
print(result.text)  # the marked document
print(result.stats.added_points)  # how many characters were added
print(result.stats.loss_tolerance)  # 0.5 -> survives losing half the shards

# Extract, with nothing but the password.
got = veilmark.extract(result.text, password="hunter2")
if got:  # truthy only when status == 'ok'
    print(got.watermark)  # 'issued-to:alice'

# Inspect without the password.
info = veilmark.detect(result.text)
print(info.status)  # 'recoverable'
print(info.encrypted, info.k, info.m, info.shards_found)

# Put the host text back.
assert veilmark.remove(result.text) == document

Command line

# Embed, reading stdin and writing stdout
veilmark embed "issued-to:alice" -i report.md -o marked.md -p hunter2

# Recover the mark
veilmark extract -i marked.md -p hunter2
# issued-to:alice

# Inspect without the password
veilmark detect -i marked.md --json

# Remove the mark
veilmark strip -i marked.md -o clean.md

Passwords are read from --password, --password-file or $VEILMARK_PASSWORD. Prefer the latter two: a passphrase in --password is visible in the process list to every user on the machine.

Exit codes let scripts branch without parsing output:

Code Meaning
0 Success; for extract, the mark was recovered and authenticated
1 Usage error, unreadable input, or the mark does not fit
2 No watermark present
3 A mark is there, but too much is gone to rebuild it
4 Rebuilt, but the password was wrong or absent

How it works

Embedding is a pipeline; extraction runs it backwards.

watermark string
  -> compress    ASCII-7 packing or raw deflate, whichever is smaller
  -> encrypt     PBKDF2-SHA256(200k) -> AES-256-GCM, fresh salt per message
  -> shard       length-prefixed payload cut into 12-byte pieces
  -> RS encode   k data shards -> k + m shards; ANY k rebuild the payload
  -> frame       shard = [idx][12 bytes]; control = [0xFF][flags][k][m][crc8]
  -> carry       spread across the document as invisible characters

Compression is tried both ways and only kept when it actually shrinks the payload; a flag bit records which transform ran. A mark of 5 000 identical characters deflates to a few dozen bytes and fits comfortably.

Encryption derives a fresh key per message from a random 8-byte salt. That is what makes the fixed all-zero GCM nonce safe: no (key, nonce) pair is ever reused, and skipping the nonce buys back 12 bytes of payload. The tag length is deliberately not recorded in the header — key derivation is the expensive part, so veilmark derives once and lets GCM itself reject the wrong lengths.

Sharding and erasure coding are where the damage tolerance comes from. The code is a systematic Cauchy Reed–Solomon over GF(2^8): the k × k identity stacked on an m × k Cauchy matrix. Every square submatrix of a Cauchy matrix is nonsingular, so any k of the n rows are independent — the MDS property. Because each shard carries its own index, a deleted chunk of text is an erasure (known position) rather than an error (unknown position), and erasure decoding fixes m losses with m parity shards where error correction would fix only m/2. Same overhead, twice the reach.

Spreading matters as much as the coding. A contiguous run is both trivially spotted and destroyed wholesale by deleting one paragraph. veilmark distributes shards across the document and interleaves the replicated control frames rather than stacking them at the front — otherwise deleting the opening third would take every copy of the parameters and the decoder would report "no watermark" with every shard still present.

Carrier alphabets

All three have a power-of-two size dividing 8 bits, so bytes map onto symbols with no padding. They are disjoint code-point sets, which is what lets the decoder identify the alphabet from the text alone.

Alphabet Code points Bits/symbol Characters per byte Use when
compat U+200B–U+200D, U+2060 2 4 Maximum platform survival; the classic zero-width set
standard (default) U+FE00–U+FE0F 4 2 Almost always. Inert, single-unit, widely preserved
dense U+FE00–U+FE0F, U+E0100–U+E01EF 8 1 Shortest output, but plane-14 characters are stripped more often in transit

Error-correction levels

m = ceil(k * t / (1 - t)), where t is the fraction of shards that may be lost.

Level Loss tolerance Overhead
L 20% lowest
M 33% low
Q 50% moderate
H 66% highest
auto (default) chosen from occupancy

auto picks the level from how much of the document the mark would occupy. A long host absorbs generous redundancy unnoticed; a short one can't, and forcing H there would make the watermark bulkier than the text protecting it.

The width channel (optional)

Every ASCII character U+0021–U+007E has a full-width twin at U+FF01–U+FF5E. Either form is a one-bit slot — nothing is inserted, existing characters are swapped in place.

Its bandwidth is one bit per punctuation mark, so a paragraph offers a few dozen bits: nowhere near the ~100 bits a single shard needs. It therefore does not carry a recoverable copy of the mark. What it carries in 3–11 bytes is a fingerprint of the payload, and it is worth having because its failure mode is orthogonal to the primary carrier's:

  • Stripping every format character kills the invisible channel outright and leaves this one intact — so a scrubbed copy can still be shown to have been marked, and matched against the copy it was issued as.
  • NFKC normalization folds full-width back to half-width, wiping this channel while leaving the invisible one alone.

When both channels survive, veilmark cross-checks them: fingerprint_matches goes false if someone splices one copy's invisible mark into another copy's body.

r = veilmark.embed(doc, "issued-to:alice", width="punct", password="k")
r.stats.width_switched  # how many characters visibly changed form

This channel visibly changes the text. It overwrites the author's own choice of full- or half-width punctuation, and remove() cannot undo it — nothing records what the original forms were. It is off by default and should stay off unless you specifically need survival against format-character stripping.

Markdown safety

By default veilmark refuses to insert anywhere that would change what Markdown renders: inside fenced code blocks, inline code spans, link and image targets, and in front of block markers (#, -, >, |, 1., thematic breaks). Pass protect_markdown=False for plain text where none of that applies.

It also never inserts directly after an emoji — where a variation selector would be absorbed as a presentation switch — nor, in the dense alphabet, after a Han character, where a plane-14 selector would form a legitimate ideographic variation sequence. Conversely, existing emoji presentation selectors and Han IVS sequences in your text are recognised as real content and left alone.

API reference

embed(text, watermark, *, password='', ecc='auto', alphabet='standard', tag_bits=64, width=False, protect_markdown=True) -> EmbedResult

Parameter Default Meaning
password '' Empty means not encrypted — obfuscated only. Label it as such wherever you surface it.
ecc 'auto' 'L', 'M', 'Q', 'H' or 'auto'
alphabet 'standard' 'compat', 'standard' or 'dense'
tag_bits 64 AES-GCM tag length: 64 or 128
width False False, 'punct' or 'all' — mirror a fingerprint into the width channel
protect_markdown True Keep the mark out of code spans, fences, link targets and block markers

Returns EmbedResult(text, stats). Raises EmptyWatermarkError, WatermarkTooLongError or UnknownAlphabetError — all subclasses of VeilmarkError.

Re-embedding replaces any existing mark rather than stacking onto it.

stats is an EmbedStats with alphabet, ecc, k, m, shards, control_replicas, encrypted, compressed, payload_bytes, original_points, final_points, added_points, added_utf8_bytes, width_slots, width_capacity_bytes, width_bytes, width_switched, loss_tolerance and occupancy. Call .as_dict() for a plain dictionary.

extract(text, *, password='') -> ExtractResult

ExtractResult.status is one of:

Status Meaning
'ok' Decoded and authenticated; watermark holds the mark
'none' No watermark here
'scrubbed' The invisible carrier is gone, but a width fingerprint survived
'damaged' A mark is present, but too many shards are gone to rebuild it
'locked' Rebuilt, but the AEAD rejected it — wrong password, or the payload was altered
'corrupt' Rebuilt and unsealed, but the bytes don't form a valid watermark

The result is truthy only when status == 'ok', so if veilmark.extract(...): reads correctly. needs_password distinguishes "you gave no password" from "the password you gave was wrong". When a width fingerprint is present, fingerprint_matches says whether the two channels describe the same copy.

'locked' deliberately does not distinguish a wrong password from an altered payload. An AEAD that could tell them apart would leak information about the key.

detect(text) -> DetectResult

Everything learnable without the password: status ('none', 'scrubbed', 'damaged' or 'recoverable'), alphabet, encrypted, compressed, width_channel, k, m, shards_found, shards_needed, loss_tolerance, lost, control_replicas, carrier_runs and fingerprint.

remove(text) -> str

Strips our carrier characters, restoring the host text. Legitimate variation sequences in your text are preserved. Width-channel switches are not reversible.

Limits and non-goals

This is not a robust watermark against a determined adversary. Anyone who knows the scheme can call remove(). It survives incidental damage — editing, excerpting, deleting paragraphs, copy-paste through most editors — not deliberate attack. If your threat model includes someone actively trying to strip the mark, this is the wrong tool, and so is every other invisible-character scheme.

Not all channels preserve invisible characters. Some platforms strip format characters on paste; plain-text email, NFKC normalization and aggressive sanitizers all destroy parts of the carrier. That is what the width channel is for, and why detect() reports 'scrubbed' as a distinct status.

Capacity is bounded. The hard limit is 3 046 bytes of payload after compression and encryption. In practice a mark should be short — an identifier, a recipient, a timestamp. Long marks need more shards, more shards need more insertion positions, and a document only has so many.

No password means no confidentiality. With password='' the payload is merely obfuscated: anyone who knows the format reads it straight out. The API allows it because obfuscation is genuinely useful for non-adversarial provenance, but say so in your UI rather than implying a security property that isn't there.

Ethics

Watermarking text means putting an identifier into something a person may go on to share. That is legitimate for tracing your own documents, marking AI-generated content, and proving provenance of your own work. It is not legitimate for covertly tracking people who haven't been told, and in many jurisdictions covert identification carries legal obligations under privacy and employment law.

If you embed a mark identifying a recipient, tell them. detect() exists partly so that anyone can check a document they've received without needing a password.

Development

git clone https://github.com/jiangmuran/veilmark
cd veilmark
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

pytest              # the suite
ruff check .        # lint

Cross-implementation tests

veilmark is a port of the text-watermark module in jmr-typebox, and the wire format is the contract between them: a mark written by either implementation must be readable by the other. Point VEILMARK_JS_SRC at the reference source to run those checks:

VEILMARK_JS_SRC=../jmr-typebox/src/features/text pytest tests/test_interop.py

They skip automatically when Node or the reference source is absent.

Credits

The design and reference implementation come from the text watermark tool in jmr-typebox. veilmark is the standalone Python port.

License

MIT

About

Invisible, error-corrected, authenticated text watermarking

Resources

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages