hashsigs-rs: one 32-byte commitment, a 4096-leaf ceiling, and a 0.2.1 crate version
Hash-based post quantum signatures in Rust
At a glance
- What is it?
- hashsigs-rs is the reference signer for the SHRINCS construction, a two-path hash-based signature scheme where a stateful UXMSS path burns one leaf per signature and a stateless SPHINCS+C path covers recovery. The parts that decide whether you can ship it are the profile constraints, the packaging, and the release gate.
- Who is it for?
- hashsigs-rs fits a team that wants a hash-based signature scheme with an on-chain verifier and is willing to own the signer side, and it does not fit as a casual dependency while the repository has no GitHub releases. Plan for the 4,096 leaf ceiling, keep the used-leaf bookkeeping the verifier refuses to do, treat 128s-q20 as off limits for production, and go through the in-tree Python backend instead of maturin directly.
- Can I use it commercially?
- Yes, with strict conditions. AGPL-3.0 is a network copyleft licence: if people use a modified version over a network, for example as a hosted service, you must offer them its source code under the same licence.
- Is it still maintained?
- Yes. The repository last received commits 7 days ago.
- What is it written in?
- Mainly Rust, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on November 13, 2026, and from our analysis. They are not legal advice.
Star history
- QuipNetwork/hashsigs-rs11,206 ★ · -7
| Repository | 2026-09-22 | 2026-11-13 |
|---|---|---|
| QuipNetwork/hashsigs-rs | 11.2k | 11.2k |
-7 Stars in the last 52 days, measured from the daily GitHub snapshot. Compare with other projects
Editorial analysis
A 32-byte commitment stands in for the 164-byte bundle on chain
SHRINCS keeps two verification paths behind one 32-byte publicKeyCommitment, tagged shrincs-public-key/<profile>. That commitment binds the stateful public key, the pkSeed and the hypertree root, so a verifier can confirm the bundle it was handed matches the value committed earlier. Only the commitment needs on-chain storage. The other 164 bytes are resupplied by the caller on every verify, and the verifier recomputes the commitment from them rather than trusting them.
What this design buys is a single 32-byte account field for a scheme carrying two roots. What it does not buy is a way to verify without the bundle. A caller that keeps only the commitment and loses those 164 bytes has nothing left to present, because nothing in the construction reconstructs a bundle from a hash of it. The profile identity sits inside the tag, so a bundle minted under one profile does not quietly verify as another. Signer state is deliberately outside all of this: nonces, used-leaf tracking and budgets belong to the integrating account, not the verifier.
The stateful path burns a leaf per signature and stops at maxSignatures
Normal signing goes through UXMSS, WOTS-C one-time signatures under an unbalanced Merkle tree that is sized to whatever maxSignatures you ask for instead of to a power of two. Each signature consumes one leaf, and the ceiling is 4,096. The leaf index is never transmitted: it equals the length of the authentication path, so the verifier recovers the position from the shape of the signature. UXMSS is crate-internal behind shrincs::uxmss, and its hashes use uxmss-* string tags in place of the RFC 8391 ADRS structure.
The limit is arithmetic, not a defect. When your leaves are gone the stateful path is finished, and the break-glass SPHINCS+C path has to take over, which is the expensive path the construction reserves for recovery and key rotation. The sharper exposure is that the verifier tracks nothing about which leaves were spent. Used-leaf tracking lives with the integrating account, so an integration that loses that bookkeeping has no on-chain check standing between it and a repeated one-time signature. If your wallet needs more than 4,096 signatures from one key, settle the rotation plan before shipping.
Grinding moved to the signer leaves the verifier with nothing to do
WOTS-C drops the checksum chains that WOTS+ relies on. The signer grinds a counter until the message digits sum to a fixed target, 480 in the 256s profiles and 240 in the 128s profiles, and the verifier checks that target-sum equation. FORS-C grinds until the last FORS tree index is zero, so a signature exposes only k minus 1 of its trees. Every signature also carries a 4-byte grind counter and a per-signature randomizer. Verification is the other half of that trade: pure keccak-256, SHA-256 for scheme hashes in the sha2 profiles, grinding nothing at all.
The declared grind bounds are profile data, 2^24 for FORS-C in the 256s set against 2^28 in the 128s set, so the smaller-hash parameter set asks the signer for more search. What the construction cannot do is make signing cheap. Signing involves grinding on both paths: WOTS-C grinds a counter in the stateful and stateless paths alike, and the stateless path adds FORS-C grinding on top, which puts that cost on your key or your server.
128s-q20 raises the budget and the README flags it as unanalyzed
Six compile-time profiles ship: three parameter sets, each in a keccak and a sha2 variant, with constants kept identical to the Solidity verifier in hashsigs-solidity and src/profiles/ as the Rust source of truth. Inside the 128s family, 128s-q20 differs from 128s-q18 in one row only, the stateless signature budget, which moves from 2^18 to 2^20. The README is blunt about what that buys: the larger q20 budget still needs security-analysis backing before production use.
So the 128s profile with the roomiest stateless budget is also the one the project asks you not to deploy yet. Choosing it to reduce how often you touch the expensive path means running a parameter set whose analysis the documentation has not finished. For an integrator the consequence is that profile selection is not a pure cost knob. The 128s-q20 profile and its sha2 twin, which share its budget, are the ones flagged as needing analysis, and because these are compile-time constants rather than runtime configuration, changing your mind later means recompiling and revalidating everything downstream.
128s halves the hash entropy and collapses the hypertree to one layer
The two parameter sets are structurally different rather than merely differently sized. The 256s set runs 32 bytes of hash entropy, a hypertree of height 64 across 8 layers, FORS-C over 22 trees of height 14, and WOTS-C with 64 chains at w = 16 summing to 480. The 128s set truncates hash entropy to 16 bytes while keeping full 32-byte wire slots, drops the hypertree to height 18 and 1 layer, uses 6 FORS-C trees of height 24, and runs 32 WOTS-C chains at w = 16 summing to 240.
Two things follow. The wire format does not shrink where a reader would expect, since truncated entropy still occupies 32-byte slots and a field you might assume is packed is not. And the shallow 128s hypertree pushes work out of the tree and into the FORS-C grind, whose declared bound is 2^28 against 2^24. The README also states that key sizes are constant across all six SHRINCS profiles, so the repeated columns in its size table are there by design, and the real variation between parameter sets shows up in signature sizes, native times and on-chain verify costs instead.
The sha2 suffix changes scheme hashes only, never the EVM-domain ones
Each parameter set has a sha2 twin, and the difference is narrow. The sha2 suite switches scheme hashes only, which is why 128s-q18-sha2 shares every row of the parameter table with 128s-q18 and 128s-q20-sha2 shares every row with 128s-q20. EVM-domain hashes, named as profile identity, commitments and canonical action hashes, stay keccak under every profile, so the 128s and sha2 profiles change hashing work and not commitment framing.
The trap is the name itself. A build labelled sha2 is not a SHA-256-only build, and reading the suffix as a claim about every hash in the system leads to misjudging both what changed and what stayed compatible. It also means a sha2 profile cannot be dropped into a context that expects uniform SHA-256 behaviour, because the commitment layer is still keccak. Anyone porting or reimplementing this scheme has to get that split right first: the scheme-hash family and the domain-hash family are separate choices here, and only the first one moves.
A bare maturin build leaves hashsigs._ext empty, and the rlib will not become a cdylib
The Python distribution is produced by an in-tree backend that wraps maturin, because maturin builds exactly one Cargo package and one Cargo package builds at most one cdylib, while this project needs one extension per SHRINCS profile.
[build-system]
requires = ["maturin>=1.14,<2"]
# An in-tree backend that wraps maturin. It builds one extension per SHRINCS
# profile and stages them into the package before handing off, because maturin
# builds exactly one Cargo package and one Cargo package builds at most one
# cdylib. Going through this backend -- `pip install .`, `python -m build` --
# is the only way to get a complete wheel; a bare `maturin build` skips it and
# produces a package whose `hashsigs._ext` is empty.
build-backend = "hashsigs_build"
backend-path = ["py"]Going around that backend is not a shortcut: the resulting package has an empty hashsigs._ext. The npm package @quip.network/hashsigs-wasm wraps the wasm build. The library target is rlib only, because a host no_std build cannot link a cdylib without a panic handler and a global allocator.
[lib]
name = "hashsigs_rs"
path = "src/lib.rs"
# rlib only: a host `no_std` build cannot link a cdylib (needs panic_handler +
# global allocator). Wasm packaging requests cdylib via `cargo rustc
# --crate-type cdylib` in bin/build-wasm.sh.
crate-type = ["rlib"]So if you script the Python build with maturin directly, you get a package whose hashsigs._ext is empty; if you need a cdylib for wasm, request it with cargo rustc --crate-type cdylib, as bin/build-wasm.sh does.
check-release gates every publish, and the crate version is still a release candidate
Publishing is guarded by an aggregate target rather than a wrapper script. check-release answers one question, is this tree publishable, and CI runs it on every pipeline through release:validate before any job touches a registry. CI invokes it with make -k so a broken crate manifest cannot hide a broken npm package inside the same pipeline, and the crate publish gate uses cargo package rather than cargo publish --dry-run so it stays out of the upload path and cannot fail on registry state. Version sync across every manifest is part of that gate.
# Every manifest carries the root Cargo.toml version.
check-versions:
bash bin/sync-versions.sh check
bash bin/tests/test-sync-versions.shThe state of that gate's output is what an adopter should read closely: Cargo.toml carries version 0.2.1, and the GitHub repository has no releases. The last push to main is dated 2026-10-30, so commits are still landing, but with no GitHub releases you have to pin the crate version or a commit yourself. Two hosts also appear in the manifests, with repository pointing at gitlab.com/quip.network/hashsigs-rs while the homepage field points at the GitHub project page, so know which one you are tracking before filing anything.
Editorial conclusion
hashsigs-rs fits a team that wants a hash-based signature scheme with an on-chain verifier and is willing to own the signer side, and it does not fit as a casual dependency while the repository has no GitHub releases. Plan for the 4,096 leaf ceiling, keep the used-leaf bookkeeping the verifier refuses to do, treat 128s-q20 as off limits for production, and go through the in-tree Python backend instead of maturin directly. Before committing, confirm which host you are tracking, because the manifests name both GitLab and GitHub.
Frequently asked questions
What is hashsigs-rs actually built out of?
It is a Rust workspace with one core crate holding wotsplus, sphincs_plus_c, shrincs and wasm bindings, plus a verify-only Solana program, a TypeScript wrapper and a Python package scaffold. The shrincs module is the two-path hybrid: a stateful UXMSS path for normal signing and a stateless SPHINCS+C path for recovery and key rotation.
Why does a bare maturin build give hashsigs-rs an empty hashsigs._ext?
maturin builds exactly one Cargo package and one Cargo package builds at most one cdylib, so this project wraps it in an in-tree backend that stages one extension per SHRINCS profile. Only `pip install .` and `python -m build` go through that backend; a bare `maturin build` skips it and the extension namespace ends up empty.
Can I run the 128s-q20 profile from hashsigs-rs in production?
Not on the strength of this repository's own documentation. 128s-q20 differs from 128s-q18 only in the stateless signature budget, 2^20 against 2^18, and the README states that the larger q20 budget still needs security-analysis backing before production use.
How much does hashsigs-rs store on chain to verify a signature?
Only the 32-byte publicKeyCommitment, tagged shrincs-public-key/<profile> and binding the stateful public key, the pkSeed and the hypertree root. The caller resupplies the full 164-byte public-key bundle on every verify, and the verifier recomputes and checks the commitment using keccak-256, or SHA-256 for scheme hashes in the sha2 profile, without grinding anything.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/quipnetwork-hashsigs-rs)Star history chart for your README
[](https://hysenlabs.com/projects/quipnetwork-hashsigs-rs)Was this useful?