This release is a pre-release and may not be stable for production use.
hashsigs
Hash-based signatures for Python: SHRINCS and SPHINCS+C, backed by the audited
hashsigs-rs crate.
SHRINCS is a two-path construction. One committed key carries a cheap bounded stateful path for normal use, and an expensive unbounded stateless path for recovery and rotation. Verification is pure hashing and needs no signer state.
Install
pip install hashsigs
Wheels target CPython 3.9 and later through the stable ABI, so one wheel per platform serves every supported version.
Quick start
import hashlib
import secrets
from hashsigs import shrincs, shrincs_keys_to_secret_bytes
# The seed is REQUIRED and must be 32 bytes. This package pulls in no random
# number generator, so you supply the entropy.
keys = shrincs.keygen(secrets.token_bytes(32), max_signatures=1024)
# Sign a 32-byte message. Pre-hash whatever you are actually signing.
message = hashlib.sha256(b"transfer 10 to alice").digest()
signature = shrincs.sign(message, keys)
assert shrincs.verify(signature, message, keys.public_key_commitment)
# Persist after every stateful signature. See "Stateful signing" below.
with open("key.bin", "wb") as handle:
handle.write(shrincs_keys_to_secret_bytes(keys))
A verifier stores only the 32-byte public_key_commitment. The signature
carries the full public key, and verification checks that key hashes to the
commitment before trusting it.
Profiles
This one distribution carries every SHRINCS profile. Each has its own module,
and the package root is the default profile, 256s-keccak:
# The default profile.
from hashsigs import shrincs
# Any other profile, from its own module.
from hashsigs.profiles import p128s_q18
| Module | Profile | Scheme hash |
|---|---|---|
hashsigs (root) |
shrincs-256s-keccak |
keccak-256 |
hashsigs.profiles.p256s |
shrincs-256s-keccak |
keccak-256 |
hashsigs.profiles.p256s_sha2 |
shrincs-256s-sha2 |
SHA-256 |
hashsigs.profiles.p128s_q18 |
shrincs-128s-q18-keccak |
keccak-256 |
hashsigs.profiles.p128s_q20 |
shrincs-128s-q20-keccak |
keccak-256 |
hashsigs.profiles.p128s_q18_sha2 |
shrincs-128s-q18-sha2 |
SHA-256 |
hashsigs.profiles.p128s_q20_sha2 |
shrincs-128s-q20-sha2 |
SHA-256 |
Every module exposes the same names. They differ only in what they compute, and
a signature made under one profile does not verify under any other. Pick one
profile per key and keep it. Persisted key bytes carry no profile tag, so
importing them under the wrong profile raises ERR_IMPORT_INVALID rather than
producing a key that signs unverifiably.
Read the loaded profile back from the extension itself, not from the import path:
from hashsigs.profiles import p128s_q18
assert p128s_q18.profile_name == "shrincs-128s-q18-keccak"
Choose on cost. The 128s profiles produce much smaller signatures and verify faster, at the price of slow signing. Keygen at 128s takes tens of seconds, against roughly 0.1 seconds at 256s.
The sha2 variants sign about three times faster than their keccak twins. They cost more gas on-chain, though, because keccak is an EVM opcode while SHA-256 is a precompile. The repository README carries the measured table.
Stateful signing
shrincs.sign consumes one leaf of a fixed budget and advances the key. This is
the part that needs care.
keys = shrincs.keygen(seed, max_signatures=4)
print(keys.stateful.remaining) # 4
signature = shrincs.sign(message, keys)
print(keys.stateful.remaining) # 3
# Persist NOW, before the next signature.
with open("key.bin", "wb") as handle:
handle.write(shrincs_keys_to_secret_bytes(keys))
Save the key after every stateful signature. Signing twice from the same
saved state reuses a one-time leaf, which breaks the guarantee the stateful path
rests on and can expose that leaf's secret material. Restore with
shrincs.import_signing_key, which revalidates the key against its own seeds
and preserves the leaf counter:
with open("key.bin", "rb") as handle:
keys = shrincs.import_signing_key(handle.read())
print(keys.stateful.remaining) # 3, not 4
max_signatures is fixed at keygen and cannot be raised later. Once the budget
is spent, stateful signing raises:
from hashsigs import HashSigsError
try:
shrincs.sign(message, keys)
except HashSigsError as err:
if err.code == "ERR_STATEFUL_LEAVES_EXHAUSTED":
... # rotate, or fall back to the stateless path
The stateless path
shrincs.sign_stateless consumes no leaf, never modifies the key, and works on
an exhausted key. It is far slower and its signatures are far larger, so it
serves recovery and rotation rather than normal traffic.
signature = shrincs.sign_stateless(message, keys)
assert shrincs.verify_stateless(signature, message, keys.stateless.public_key)
shrincs.reset(keys, new_seed) starts a fresh stateful chain after suspected
leaf reuse. The stateless half and max_signatures survive, but
public_key_commitment changes, so anything pinning the old commitment
stops accepting the key.
Errors
Every failure raises HashSigsError with a stable code. Branch on the code,
never on the message text. Messages never echo the input that caused them,
because that input is routinely secret key material and messages routinely
reach logs.
| Code | Meaning |
|---|---|
ERR_BAD_LENGTH |
A fixed-width argument had the wrong length |
ERR_INVALID_INPUT |
max_signatures outside 1 to 4096 |
ERR_KEYGEN_FAILED |
Key derivation failed for these inputs |
ERR_IMPORT_INVALID |
Recomputed roots do not match the seeds, or the counter is out of range |
ERR_STATEFUL_LEAVES_EXHAUSTED |
Every stateful leaf is spent |
ERR_SIGNING_FAILED |
Grinding failed for this key and message |
ERR_ENVELOPE_MALFORMED |
The signature is not a well-formed envelope |
Verification never raises. A malformed signature, a wrong-length key, or a
commitment mismatch is simply False.
Building from source
pip install build
python -m build # wheel + sdist
pip install dist/*.whl
Or straight from a checkout:
pip install .
Both go through py/hashsigs_build.py, a PEP 517 backend that compiles one
extension per profile and stages them into the package before handing off to
maturin. Use it rather than calling maturin directly: maturin builds one
Cargo package, and a bare maturin build produces a wheel whose
hashsigs._ext is empty.
Installing from source works the same way. pip install hashsigs --no-binary hashsigs runs that backend from the unpacked sdist and rebuilds every
extension, so it needs a Rust toolchain.
The wheel carries one compiled extension per profile under hashsigs._ext, so
importing a profile maps only that profile's code. Each extension links its own
copy of the crate, at about 715 KB each, so the six add roughly 4.3 MB against
715 KB for a single-profile build. The released wheel is about 2.0 MB
compressed. That is the trade this layout makes, and unlike a browser bundle
there is no per-import transfer saving to offset it.
License
AGPL-3.0-or-later. See COPYING.
Metadata
Release files for hashsigs 0.2.1rc6
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| hashsigs-0.2.1rc6.tar.gz | 3.2 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| hashsigs-0.2.1rc6-cp39-abi3-manylinux_2_34_x86_64.whl | CPython 3.9 | abi3 | Linux glibc 2.34+ x86-64 | Details |
Total release size: 5.3 MB
Release files / hashsigs-0.2.1rc6.tar.gz
| Download URL | hashsigs-0.2.1rc6.tar.gz |
|---|---|
| Size | 3.2 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5fa9d7609dd11015ccc5fdabec7cc0f653cb0823938403a95652aee6d4301009
|
|
BLAKE2b-256 checksum How to use checksums |
db2c0dca263fcba94a218681f4447e30eacb9714054ae6de41066becb194cc6d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|
Release files / hashsigs-0.2.1rc6-cp39-abi3-manylinux_2_34_x86_64.whl
| Download URL | hashsigs-0.2.1rc6-cp39-abi3-manylinux_2_34_x86_64.whl |
|---|---|
| Size | 2.0 MB |
| Tags | CPython 3.9 Linux glibc 2.34+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
01c0eed6cd1bbd5462e06773ae62a81b8be080d3da1a182ea7611d3234dd9aee
|
|
BLAKE2b-256 checksum How to use checksums |
156aa5a689195a6727b1f8a8640208b13a2c3a2a016cd39e2be2312b3b1d9511
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|