Invisible, error-corrected, authenticated text watermarking for Python.
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'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
kof thek + mshards 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.
pip install veilmarkPython 3.10+. One dependency: cryptography.
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# 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.mdPasswords 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 |
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.
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 |
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.
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 formThis 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.
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.
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.
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.
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.
Strips our carrier characters, restoring the host text. Legitimate variation sequences in your text are preserved. Width-channel switches are not reversible.
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.
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.
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 . # lintveilmark 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.pyThey skip automatically when Node or the reference source is absent.
The design and reference implementation come from the text watermark tool in jmr-typebox. veilmark is the standalone Python port.