hs signs blocks of an HTML (SGML/XML) document and lets anyone verify
them — even after the bytes have crossed the network. A TLS connection can
prove who you are talking to, but it can't prove the content they serve
is the content you intended. hs freezes the exact bytes of a block into a
self-contained, post-quantum signature attribute.
<div class="text" data-hs-signature="SHA3-256+ML-DSA-65+BASE64:QWsd....">
<p>Some text</p>
<img src="image.jpg">
</div>- Sign any block matched by a CSS selector (
div.text,#price,article[data-id="42"], …). - The signature is computed over the entire block, including the root
element and all attributes except
data-hs-signatureitself. - Minification-proof: text whitespace is normalized before signing, so a server can minify or reformat the block without breaking its signature (content, attribute, and structural changes still fail).
- The signature is a plain ML-DSA signature — the public key is not
embedded. Verification always uses a key you supply:
verify -k key.pub, the default key file, or the key pinned in the_hs_key.<host>DNS record for URL verification. - Verify finds every signed block, recomputes its canonical bytes, and checks the ML-DSA signature against that key.
- Keys are stored passphrase-encrypted (Argon2id + XChaCha20-Poly1305), never in plaintext on disk.
| Layer | Algorithm | Crate |
|---|---|---|
| Key Encapsulation (KEM) | ML-KEM (CRYSTALS-Kyber) FIPS 203 | ml-kem |
| Digital Signatures | ML-DSA (CRYSTALS-Dilithium) FIPS 204 | ml-dsa |
| Symmetric encryption | XChaCha20-Poly1305 | chacha20poly1305 |
| Key derivation | Argon2id (passphrase → symmetric key) | argon2 |
| Compression | Zstandard / GZIP / BZIP2 / LZMA / DEFLATE | zstd / flate2 / bzip2 / lzma-rs |
| Binary encoding | BASE64 / ASCII85 | base64 / ascii85 |
| Randomness | OsRng |
rand |
Pure-Rust crates only. The pqcrypto umbrella crate was deliberately avoided
due to RUSTSEC-2026-0164 (unmaintained).
Generate a passphrase-protected key pair:
$ hs gen-key --public-key key.pub
Enter passphrase for new key:
Confirm passphrase:
Generated key pair:
secret key: ~/.local/share/hs/keys/default.hskey
kem: ML-KEM-768
dsa: ML-DSA-65
fingerprint: 7f6a2c...c3d09bThe public key is exported in armored form:
-----BEGIN HS PUBLIC KEY-----
ML-KEM-768 ML-DSA-65
9Xx... (base64 body, wrapped at 64 columns)
-----END HS PUBLIC KEY-----
Inspect a key file:
$ hs view-key
Key file: ~/.local/share/hs/keys/default.hskey
kem: ML-KEM-768
dsa: ML-DSA-65
fingerprint: 7f6a2c...c3d09b$ hs sign index.html div.text
Signed 1 block(s) in index.html
key fingerprint: 7f6a2c...c3d09b
<div> signed 47 bytesOptions: -k key.hskey, -o out.html, --no-passphrase,
--passphrase-file FILE.
If a matched block already sits inside another signed block, hs skips
it — it is covered by the enclosing signature — and prints a warning:
$ hs sign index.html .inner
warning: <div> is inside another signed block and was not signed
Signed 0 block(s) in index.htmlVerify a local file against the default key (~/.local/share/hs/keys/default.hskey):
$ hs verify index.html
key: /home/user/.local/share/hs/keys/default.hskey
[0] <div> OK
fingerprint: 7f6a2c...c3d09b
OK: 1 of 1 blocks verified.Verify against a specific key with -k:
$ hs verify index.html -k key.pub
key: key.pub
[0] <div> OK-k accepts either an armored public key file (key.pub) or the
.hskey secret key file itself — the tool detects the format and unlocks
the secret key (prompting for its passphrase) to use its public half. The
key: line reports where the key used for verification came from (a file
or the DNS pin record).
Tampered content fails loudly:
$ hs verify tampered.html
key: /home/user/.local/share/hs/keys/default.hskey
[0] <div> FAIL
reason: signature does not match block content
FAIL: 0 of 1 blocks verified.For automation, --format json emits the result as machine-readable JSON —
with ok, total, verified, a key object describing where the key is
located (source is file or dns), a warnings array (signed blocks
nested inside other signed blocks), and a blocks array (each entry has
element, valid, fingerprint, and reason):
$ hs verify index.html -k key.pub --format json
{
"ok": true,
"total": 1,
"verified": 1,
"key": { "source": "file", "location": "key.pub" },
"warnings": [],
"blocks": [
{
"element": "div",
"valid": true,
"fingerprint": "7f6a2c...c3d09b",
"reason": null
}
]
}The exit status is non-zero whenever the verification fails (any invalid
block), so hs verify --format json drops straight into a CI pipeline.
hs verify accepts an http:// or https:// URL instead of a local file:
$ hs verify https://example.org
Fetching https://example.org ...
[0] <div> OK
fingerprint: 7f6a2c...c3d09b
OK: 1 of 1 blocks verified.When given a URL, hs:
- Fetches the document over HTTPS and validates the server's TLS
certificate, failing hard on an invalid cert unless
--ignore-tls-errorsis passed. - Reads the DNS TXT record
_hs_key.example.org, which pins the signing key: it holds the key's SHA3-256 fingerprint and the URL where the key is served.hsdownloads the key from that URL and requires its fingerprint to match the pin exactly, then requires every signed block to match that key.
Publish the pin record from hs export -k key.hskey --txt --url <URL> at
_hs_key.<your-domain> and serve the armored public key (hs export -k key.hskey -o key.pub) at <URL>. This closes the gap TLS leaves open: TLS
authenticates the endpoint, the _hs_key pin authenticates the key, and
the signature binds the content.
Export the public key of an existing key file without regenerating anything.
Without --txt, the armored form is printed for out-of-band distribution
or for serving at a well-known URL:
$ hs export -k ~/.local/share/hs/keys/default.hskey
-----BEGIN HS PUBLIC KEY-----
ML-KEM-768 ML-DSA-65
...
-----END HS PUBLIC KEY-----Serve the armored public key at a URL (e.g. https://example.org/.well-known/hs.pub),
then print the DNS pin record that ties that URL to the key's fingerprint
with --txt --url:
$ hs export -k key.hskey --txt --url https://example.org/.well-known/hs.pub
HSPIN:SHA3-256:7f6a2c...c3d09b:https://example.org/.well-known/hs.pubPaste that single short line as the _hs_key.<host> TXT record — it is
well under the 255-byte character-string limit. hs verify <URL> downloads
the key, checks its SHA3-256 fingerprint against the pin, and fails loudly
on any mismatch. Write to a file with -o. The private key is never
exported — it stays in the encrypted .hskey.
hs gen-key [-o PATH] [--kem ML-KEM-768] [--dsa ML-DSA-65]
[--public-key PATH] [--no-passphrase] [--passphrase-file FILE]
[--argon2-mem KIB] [--argon2-time N] [--argon2-par N]
hs sign FILE SELECTOR [-k KEY.hskey] [-o OUT.html]
[--no-passphrase] [--passphrase-file FILE]
hs verify FILE|URL [-k KEY.pub|KEY.hskey] [--ignore-tls-errors]
[--format text|json]
[--no-passphrase] [--passphrase-file FILE]
hs view-key [-k KEY.hskey] [--no-passphrase] [--passphrase-file FILE]
hs export [-k KEY.hskey] [-o KEY.pub] [--txt --url URL]
[--no-passphrase] [--passphrase-file FILE]
Global flags: -n, --dry-run prints what would happen and exits without
doing anything.
make build # cargo build
make test # cargo test
make clippy # cargo clippy --all-targets -- -D warnings
make fmt # cargo fmt
make doc # cargo doc --no-deps
make audit # cargo auditOr directly:
cargo build
cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --check- Signature attribute format:
data-hs-signature="SHA3-256+ML-DSA-65+BASE64:<signature>"— the payload is the ML-DSA signature only; the public key is not embedded. - Hash-then-sign 🧬: the ML-DSA signature covers the 32-byte SHA3-256
digest of the block's canonical bytes (marked by the
SHA3-256entry in the algorithm list), so signing cost is independent of block size. Legacy signatures that covered the raw canonical bytes are still verified. - Key supplied at verify time 🔑: the trust anchor is the key you give
hs verify(-k, the default key file, or the_hs_key.<host>DNS pin). Nothing about the document is trusted; the key binds the content. - Minification-proof 🔧: the signature is computed over a canonical form
of the block in which text whitespace is normalized — runs collapse to a
single space, leading/trailing whitespace is trimmed, and whitespace-only
text nodes (indentation, line breaks) are dropped. A server can minify or
reformat the block and every signature still validates; changing actual
content, attributes, or structure still fails verification. Whitespace
inside
<pre>,<textarea>,<script>, and<style>is preserved verbatim because it is semantically significant. - DNS-pinned keys 🌐: remote verification pins the key's SHA3-256
fingerprint in the
_hs_key.<host>TXT record and downloads the key from the pinned URL, validating the digest on every check. The record is a shortHSPIN:SHA3-256:<fingerprint>:<url>line — no 4096-byte limit, no quoting pitfalls — and a compromised server cannot swap in a different key without the pin failing. - Nested signed blocks 🪆: when signing a block that already contains
signed blocks, the inner signed subtrees are excluded from the outer
signing payload — re-signing or changing an inner block never breaks the
outer signature. Signing a block that already sits inside another signed
block is a no-op (
hs signskips it and reports it), since the enclosing signature already covers it. Each inner block keeps its own signature and is checked separately;verifyreports the nesting as a warning (<div> in line 333 is outside <section> signature) in both text and JSON output. - Memory safety: no
unsafe, secret material is zeroized, and all key material on disk is passphrase-encrypted.
src/
lib.rs crate-level API documentation
cli.rs clap CLI definitions
crypto/ ML-KEM, ML-DSA, symmetric primitives, key file format
ascii85.rs compact Base85 encoding for DNS TXT public keys
format.rs signature attribute encoding/parsing
html.rs HTML parsing, signing, verification, report rendering
keys.rs key generation, storage, public key armor
net.rs HTTPS fetch + DNS `_hs_key` key resolution
main.rs binary entry point and command dispatch