Verifiable invisible signatures for plain text. Sign a document with your Ed25519 private key and the signature is embedded as a string of invisible Unicode characters — anyone with your public key can verify the text is authentic and unmodified, and nobody reading it sees a thing.
Pure Node.js (>= 20), zero dependencies, no build step.
# one-time: generate a keypair in ~/.config/ghostsig
node bin/ghostsig.js keygen
# sign (stdin -> stdout)
echo "I wrote this." | node bin/ghostsig.js sign > signed.txt
# verify
node bin/ghostsig.js verify signed.txt # OK: signature valid (key id 1a2b3c4d)
# show embedded metadata without verifying
node bin/ghostsig.js inspect signed.txt
# remove the signature
node bin/ghostsig.js strip signed.txtInstall globally with npm link (or npm i -g .) to get the ghostsig
command on your PATH.
keygen writes to $GHOSTSIG_DIR, else $XDG_CONFIG_HOME/ghostsig, else
~/.config/ghostsig:
| file | contents | mode |
|---|---|---|
ghostsig.pem |
private key (PKCS8) | 0600 |
ghostsig.pub |
public key (SPKI) | 0644 |
All commands accept -k <file> to use a specific key instead. Keys are
standard PEM, so you can also generate them with openssl genpkey -algorithm ed25519 if you prefer.
- Canonicalize the text (see below) and sign it with Ed25519.
- Frame the payload:
magic "GS" (2B) ‖ version (1B) ‖ key id (4B) ‖ signature (64B)— 71 bytes. The key id is the first 4 bytes of the SHA-256 of the raw public key, so verifiers can tell which key signed. - Encode: base64 the payload (96 chars), then map each char
cto Unicode codepointU+E0000 + c— the Tags block (U+E0020–U+E007E), which renders as nothing in virtually all fonts and survives copy-paste, email, Slack, and most CMSes. - Append the invisible string to the end of the text.
Verification reverses this: extract tag characters → decode → canonicalize the remaining visible text → check the Ed25519 signature and key id.
Editors routinely normalize documents in transit, which would break a naive byte-for-byte signature. By default, ghostsig signs/verifies the text after:
- Unicode NFC normalization
- CRLF / CR → LF line endings
- Stripping trailing spaces and tabs at end of lines
So verify still passes after a document round-trips through Slack or a web
form. Pass --raw to sign/verify to skip this and sign the exact bytes.
import { generateKeyPair, sign, verify, extract, strip } from 'ghostsig';
const { publicKey, privateKey } = generateKeyPair();
const signed = sign('I wrote this.', privateKey);
verify(signed, publicKey);
// { ok: true, keyId: '1a2b3c4d' }
verify('tampered' + signed.slice(8), publicKey);
// { ok: false, reason: 'invalid-signature', keyId: '1a2b3c4d' }
extract(signed); // { version: 1, keyId: '1a2b3c4d', signature: <Buffer> }
strip(signed); // 'I wrote this.'verify never throws on bad input; reason is one of 'no-signature',
'malformed', 'key-id-mismatch', 'invalid-signature'. sign and
verify accept { raw: true } to skip canonicalization. Key helpers
(saveKeyPair, loadPrivateKey, loadPublicKey, fingerprint,
configDir) are also exported.
| code | meaning |
|---|---|
| 0 | success / signature valid |
| 1 | verification failed or no signature found |
| 2 | usage error, missing key, or I/O error |
- It proves authenticity when present — nothing when absent. Anyone who knows the scheme can strip the characters with one line of code. This is an opt-in authenticity watermark, not DRM.
- Signatures are fragile across transforms. Retyping, screenshots/OCR, printing, and some sanitizers destroy the characters. Test your actual distribution channel.
- The content is not encrypted or hidden — only signed.
- Security scanners may flag it. Unicode Tag characters are associated with "ASCII smuggling" prompt-injection research, so some tools warn on documents containing them.
npm test # node:test suite, no dependencies to install