A tool that generates guilloche patterns as compact EPS files, deterministically derived from document content and a secret key.
Physical documents have wax seals, embossed stamps, watermarks — artefacts that are hard to reproduce without the original die. Digital documents have... signatures that are invisible blobs of base64.
Guilloche patterns — the intricate interlocking curves on banknotes and certificates — occupy an interesting middle ground. They're:
- Visually distinctive: humans can spot differences at a glance
- Parametrically rich: small input changes produce completely different patterns
- Impossible to reverse-engineer: knowing the algorithm doesn't help without the key
The idea is simple: take some key facts from a document (totals, page numbers, references), combine them with a secret, and generate a unique visual stamp. Print it on every page. Anyone with the secret can regenerate the stamp and verify — anyone without it can only compare two stamps by eye.
document facts + secret key
│
▼
canonical string e.g. "doc=INV-2024-003&page=1&total=42.50"
│
▼
HMAC-SHA256 32 bytes of pseudorandom output
│
▼
parameter extraction sequential byte consumption → pattern params
│
▼
PostScript template ~3KB EPS with curves computed in the PS interpreter
The security rests on HMAC preimage resistance in the Random Oracle Model:
- Public: the algorithm, the parameter mapping, the canonical input string
- Secret: the HMAC key
- Verification: regenerate from the document facts + secret, compare the digest
The visual pattern is a lossy projection of the 256-bit hash — roughly ~39 effective bits of visual entropy (4 layers × ~13 bits each at ~60% visual independence, plus ~3 bits of density and ~6 bits of border modulation). That's enough that two stamps from different inputs are overwhelmingly likely to look different at a glance. The pattern provides a quick visual sanity check ("does this look right?"). The real verification is cryptographic: rerun the tool, compare digests.
An attacker who wants to forge a stamp for different document facts would need to find an input that produces a matching HMAC-SHA256 output — which requires knowing the key. Even with full knowledge of the algorithm, the stamp is unforgeable.
The 32-byte HMAC-SHA256 digest is consumed sequentially, one byte per parameter:
bytes 0–19: 4 layers × 5 bytes (R, r, d, phase, hue)
byte 20: revolutions
byte 21: border waves
byte 22: border amplitude
bytes 23–31: unused (reserved for future use)
Each byte is mapped to its parameter range via lo + (byte / 255) × (hi − lo).
Four hypotrochoid layers (the spirograph curves), each with:
| Parameter | Byte | Range | Controls |
|---|---|---|---|
| R (outer radius) | i×5 |
40–120 | Size of the outer circle |
| r (inner radius) | i×5+1 |
10–R×0.8 | Rolling circle size (determines lobe count) |
| d (pen offset) | i×5+2 |
r×0.3–r×1.5 | Pen distance from centre |
| phase | i×5+3 |
0–2π | Rotational offset |
| hue | i×5+4 |
0–1 | Layer colour (HSL, saturation=0.7, lightness=0.35) |
Density: byte 20 maps to 8–40 full revolutions of the curve.
Border modulation: byte 21 gives 3–18 sinusoidal waves on the bounding circle, byte 22 gives 1–5% amplitude. Both the outer and inner borders are modulated.
The dependent ranges (r depends on R, d depends on r) mean the interpretation is chained but the bytes themselves are independent — HMAC-SHA256 guarantees that any input change avalanches across all 32 bytes, so every parameter shifts simultaneously.
Each layer traces a hypotrochoid:
x(t) = (R − r)·cos(t) + d·cos((R−r)/r · t)
y(t) = (R − r)·sin(t) − d·sin((R−r)/r · t)
The ratio (R−r)/r determines the number of lobes — the most visually dominant
feature of each layer. Since R and r come from independent hash bytes, this ratio
is unpredictable, giving each stamp a unique structural fingerprint.
The curves are computed entirely inside the PostScript interpreter using for loops
with sin/cos. This means:
- Tiny files: ~3KB regardless of curve complexity (no path data, just loop parameters)
- Resolution-independent: the interpreter computes at whatever DPI the renderer uses
- Embeddable: EPS drops into LaTeX via
\includegraphics, pandoc via standard includes - Inspectable: the EPS is human-readable ASCII with the digest in a comment
For document integration, the stamp can be split into four quadrants — one for each page corner. When the page is folded inward, the four quarters complete the full pattern. Each quadrant EPS is cropped via its bounding box, with the full pattern geometry preserved so the curves align perfectly at the edges.
Three byte-identical implementations are provided:
| Language | File | Dependencies |
|---|---|---|
| Python 3 | guilloche.py |
None (stdlib only) |
| C99 | guilloche.c |
-lcrypto -lm |
| ES2019 | guilloche.mjs |
None (inline SHA-256) |
All three produce identical EPS output for the same inputs. Run test.sh to verify.
from guilloche import stamp, digest_from_kv, Params
# One-liner
eps = stamp({"doc": "INV-2024-003", "page": "1", "total": "42.50"}, secret="key")
# Step by step
digest = digest_from_kv({"doc": "INV-2024-003", "page": "1"}, secret="key")
eps = stamp(digest=digest, quadrant=1)import { stamp, digestFromKv } from "./guilloche.mjs"
// One-liner
const eps = stamp({
pairs: {doc: "INV-2024-003", page: "1", total: "42.50"},
secret: "key"
})
// With pre-computed digest
const eps = stamp({digest: someBytes, quadrant: 1})TypeScript declarations are provided in guilloche.d.ts.
All three implementations share the same flags:
-s SECRET HMAC secret key
-k key=val Key-value pair (repeatable)
-d HEX Raw 64-char hex digest
-r Random digest
-o PATH Output EPS (default: stamp.eps)
-S SIZE Canvas size in points (default: 400)
-q Emit 4 quadrant files (_q1.._q4)
-p Print parameters
# From key-value pairs
./guilloche.py -s "my-secret" -k doc=INV-2024-003 -k page=1 -k total=42.50 -o stamp.eps
# Quadrant output for page corners
./guilloche.py -s "key" -k doc=INV-2024-003 -k page=1 -q -o stamp.eps
# Produces: stamp_q1.eps (TR), stamp_q2.eps (TL), stamp_q3.eps (BL), stamp_q4.eps (BR)
# Random (for testing)
./guilloche.py -r -o test.eps
# From a raw hex digest
./guilloche.py -d a1b2c3d4e5f6... -o stamp.eps
# C version
cc -std=c99 -O2 -Wall -Wextra -pedantic -o guilloche guilloche.c -lcrypto -lm
./guilloche -s "my-secret" -k doc=INV-2024-003 -k page=1 -k total=42.50 -o stamp.eps
# Node version
node guilloche.mjs -s "my-secret" -k doc=INV-2024-003 -k page=1 -k total=42.50 -o stamp.epsKeys are sorted alphabetically and URL-encoded into a canonical query string
(e.g. doc=INV-2024-003&page=1&total=42.50). Key order on the command line
doesn't matter.
- Read the canonical string from the document footer
- Run any implementation with the same
-kpairs and-ssecret - Compare the generated stamp against the one on the document
- For cryptographic certainty, compare the HMAC-SHA256 hex output
Python: No build step. Requires Python 3.
C:
cc -std=c99 -O2 -Wall -Wextra -pedantic -o guilloche guilloche.c -lcrypto -lmWorks with OpenSSL, BoringSSL, and LibreSSL on Linux, macOS, Windows, and BSD.
JavaScript: No build step. Works in Node 12+ and modern browsers.
./test.shBuilds the C version and verifies all three implementations produce byte-identical EPS output across multiple test cases (key-value pairs, hex digests, edge cases, quadrants, custom sizes).
