hashsigs-rs
Core Rust hash-signature workspace with:
hashsigs-rs: one crate containing:wotsplusprimitivesshrincssigner / verifier primitiveswasmverifier / signer bindings
solana/: Solana program integration
Building
To build the library:
For release build:
To build the Solana program:
WASM Packaging
The crate exposes a noble-style SPHINCS+C/SHRINCS signer surface under
src/wasm/ behind the wasm-bindings feature. The supported build path is
bin/build-wasm.sh, which runs cargo build for wasm32-unknown-unknown and
then the wasm-bindgen CLI (not wasm-pack) for the nodejs and web
targets.
Prerequisites:
# Must equal the crate's wasm-bindgen dependency (Cargo.toml =0.2.100).
Build from the crate root (default output directory is ts/src):
# or
That writes:
ts/src/nodejs/ # wasm-bindgen nodejs target (CommonJS)
ts/src/web/ # wasm-bindgen web target (ESM)
Optional custom output directory:
The TypeScript package that wraps those bindings lives in ts/ and is named
@quip.network/hashsigs-wasm. After the wasm build:
Published consumers load one async entry point. The package "browser" field
swaps the Node loader for the browser loader at bundle time:
import { loadHashSigs } from "@quip.network/hashsigs-wasm";
const { shrincs } = await loadHashSigs();
const seed = crypto.getRandomValues(new Uint8Array(32));
const keys = shrincs.keygen(seed, 16);
CI builds and tests this package on merge requests and the default branch
(ts-conformance job). Version tags matching vX.Y.Z (optional pre-release
suffix) run the same build and publish to npm.
Current WASM scope:
- supported:
- noble-style Uint8Array signer/verifier entry point (
loadHashSigs()) for SPHINCS+C and SHRINCS keygen, sign, and verify - Node and browser packaging under
@quip.network/hashsigs-wasm
- noble-style Uint8Array signer/verifier entry point (
- not implemented:
- WOTS-specific wasm bindings
- a separate
wasm-pack/pkg/<target>layout
SHRINCS Profiles
Rust currently supports the same SHRINCS profile identities as the active Solidity implementation:
shrincs-256s-keccakshrincs-256s-sha2shrincs-128s-q18-keccakshrincs-128s-q20-keccak
The profile selects the compile-time parameter tuple and profile identity. The scheme-hash suite follows the selected profile:
256s-keccak,128s-q18-keccak,128s-q20-keccak: internal scheme hashes use keccak256s-sha2: internal scheme hashes use SHA-256
build.rs is the single owner of Rust-side profile selection and profile
identity generation. It selects exactly one active profile for the build and
emits the corresponding profile cfg plus generated identity constants.
Profile identity follows the Solidity SHRINCSParams model:
PROFILE_NAMEis the canonical suite-qualified profile stringPROFILE_IDis derived askeccak256(PROFILE_NAME)- Rust generates that identity at build time so the name and ID cannot drift
EVM-domain hashes remain keccak under every profile so Rust stays aligned with the Solidity verifier on:
- profile identity framing
- hybrid public-key commitments
- canonical action-message hashes
The ignored vector generator writes one golden file per compiled profile:
tests/test_vectors/shrincs_sphincs_256s_keccak.jsontests/test_vectors/shrincs_sphincs_256s_sha2.jsontests/test_vectors/shrincs_sphincs_128s_q18_keccak.jsontests/test_vectors/shrincs_sphincs_128s_q20_keccak.json
Testing Profiles
Run the default profile (shrincs-256s-keccak):
Run a specific non-default profile:
For a fast compile-only check:
Select at most one explicit profile feature at a time:
- default build selects
shrincs-256s-keccak profile-256sprofile-256s-sha2profile-128s-q18profile-128s-q20
To regenerate the ignored SHRINCS golden vectors for the active profile:
Fast Local Loops
During development, prefer a narrow local loop over rerunning the full matrix
after every edit. bin/test-fast.sh wraps the common targeted commands:
Typical usage:
- use
compile-defaultwhen you only need a fast native compile check - use
signer-stateful,signer-import,signer-boundary,signer-stateless,signer-import-exact <test-name>, orsigner-exact <test-name>while editing SHRINCS signer code - use
vectors-shrincswhen you only care about the SHRINCS Solidity-exported vector cross-checks, orvectors-exact <test-name>for one exact vector test - use
wasmfor native wasm-module tests,wasm-exact <test-name>for one wasm case, andwasm-compilefor wasm target compile coverage - use
solidity-exact <test-name>when you only want onesolidity_account_vectorscase - use
wasm-compilefor wasm target compile coverage without trying to execute the.wasmartifact locally - use
wasm-nodeonly when you want the actual Node-based wasm runtime tests - run
cargo testor./bin/test-shrincs-profiles.shonly after the narrow loop is clean
For an automatic polling loop on file changes:
test-watch.sh watches the crate's Rust, test, script, and build files and
reruns the selected test-fast.sh area whenever something changes.
SHRINCS Layout
src/shrincs/ is flat: it has no core, components, signers, or
verifiers subdirectories. Its files:
mod.rs— module root; owns commitment derivation and action-hash dispatch, and composes the independentsphincs_plus_c(stateless) anduxmss(stateful) modules.key.rs(pub) — the composedKeystype, theCommitmentnewtype (Commitment::of,Commitment::from_bytes), thePublicKeybundle wire type and its ABI codec, andcompute_commitment/recompute_commitment/recover_commitment/import/resetplus 264-byte serialization. Consolidates the formerpublic_key.rsto mirrorsphincs_plus_c::key.signer.rs(pub) —ShrincsSigner(key generation, signing-key import, and stateless signing) and the freesignfunction (stateful signing that advances the key in place). Folds in the formersigner_types.rsandsigner_utils.rshelpers.signature.rs(pub) — the stateful signature wire type and the composite signature codecs.verifier.rs(pub) —ShrincsVerifier:verify,verify_stateful, andverify_stateless.uxmss.rs(pub(crate)) — the stateful half (UXMSS over WOTS+):SkSeed/PrfSeed/PkSeed/Rootnewtypes,PrivateKey/PublicKey/Key, and stateful signing.dispatch.rs— internal action-hash dispatch glue.vector_conformance.rs— vector-conformance tests.test_fixtures.rs(pub(crate)) — test fixtures.
The stateless half, sphincs_plus_c, is a sibling top-level module at
src/sphincs_plus_c/, not a child of shrincs/. FORS-C and hypertree logic
live there (fors_c.rs, hypertree.rs); the scheme-neutral building blocks
(hash/, abi.rs, buf.rs, profiles.rs, treehash.rs) sit at the crate
root.
WASM Testing
Two layers cover the wasm surface:
- Rust host tests (
cargo test --features wasm-bindings): byte-length validation and feature-gated conversion logic on the host. They don't run the exported bindings inside a wasm runtime. - TS packaging conformance (
cd ts && npm test, afternpm run build): loads the builtdist/package through both Node and browser loaders and exercisesloadHashSigs()(keygen, sign, verify, stateful-leaf advance, import).
For Rust-only wasm target unit tests (optional), install a matching
wasm-bindgen-test-runner and run:
When changing WasmShrincsKeys or WasmSphincsPlusCKeys in src/wasm/,
treat the TS conformance suite as the packaging gate and the Rust suite as
the crypto gate.
WASM API
loadHashSigs() is the noble-style entry point. It awaits the wasm module
once and resolves to { sphincsPlusC, shrincs, shrincsImportSigningKey } —
two namespace objects plus one standalone function. Keys decompose into
nested objects (never a flat secretKey/publicKey field). Every leaf in
those objects and every sign/verify argument is a Uint8Array. The surface
carries no hex strings. After the initial await, every call is
synchronous.
keygen and reset require a caller-supplied 32-byte seed. The library has
no RNG: pass cryptographically secure random bytes, such as
crypto.getRandomValues(new Uint8Array(32)) in the browser or Node's
crypto.randomBytes(32)/webcrypto. A weak seed produces a weak key, and
nothing in the library checks seed quality. See
SECURITY.md.
Messages are exactly 32 bytes. Callers pre-hash arbitrary data and pass the
32-byte digest, matching how the on-chain verifier treats its hash argument
as the signed message. A wrong-length message throws on sign and returns
false on verify; verify never throws.
SPHINCS+C (stateless, standalone)
import { loadHashSigs } from "@quip.network/hashsigs-wasm";
const { sphincsPlusC } = await loadHashSigs();
const seed = crypto.getRandomValues(new Uint8Array(32));
const keys = sphincsPlusC.keygen(seed);
// keys.secret: { skSeed: Uint8Array(32), prfSeed: Uint8Array(32) }
// keys.publicKey: { pkSeed: Uint8Array(32), root: Uint8Array(32) }
const sig = sphincsPlusC.sign(message32, keys);
const ok = sphincsPlusC.verify(sig, message32, keys.publicKey); // boolean
sign is stateless: it never mutates keys. verify never throws — a
malformed signature or wrong-length input is simply false.
SHRINCS (hybrid, stateful with stateless recovery)
import { loadHashSigs } from "@quip.network/hashsigs-wasm";
const { shrincs } = await loadHashSigs();
const seed = crypto.getRandomValues(new Uint8Array(32));
const keys = shrincs.keygen(seed, maxSignatures); // maxSignatures defaults to 1024
// keys.stateless: SphincsPlusCKeys — never changes after keygen
// keys.stateful: { secret, publicKey, nextLeafIndex, remaining } — advances on sign()
// keys.publicKeyCommitment: Uint8Array(32)
const sig = shrincs.sign(message32, keys); // STATEFUL: advances keys.stateful in place
const recovery = shrincs.signStateless(message32, keys); // stateless recovery path, no mutation
// shrincs.verify checks the commitment path: it hashes the public key the
// signature carries and compares against the pinned commitment.
const ok = shrincs.verify(sig, message32, keys.publicKeyCommitment);
// A stateless SHRINCS signature is a SPHINCS+C signature, so verifyStateless is
// a SPHINCS+C verify: pass keys.stateless.publicKey.
const okRecovery = shrincs.verifyStateless(recovery, message32, keys.stateless.publicKey);
shrincs.signStateless produces the same bytes as sphincsPlusC.sign under the
keypair's stateless key, and shrincs.verifyStateless(sig, msg, keys.stateless.publicKey)
is exactly sphincsPlusC.verify(sig, msg, keys.stateless.publicKey).
shrincs.sign is stateful:
- each call consumes one one-time UXMSS leaf and advances
keys.stateful(nextLeafIndex,remaining) in place — the same object the caller holds gets mutated, so the nextsigncall automatically uses the next leaf. No new key object comes back. - once the stateful budget runs out, it throws an
Errorwitherror.code === "ERR_STATEFUL_LEAVES_EXHAUSTED". Callshrincs.signStatelessfor unlimited recovery-path signing past that point, orshrincs.reset(keys, newSeed)to start a fresh stateful chain —resetrequires a new 32-byte seed (no library RNG, same rule askeygen), produces a newpublicKeyCommitment, and leaveskeys.statelessuntouched.
Footgun: signing from a copy of keys taken before an earlier sign call
reuses a leaf, which breaks the one-time-signature security the scheme
depends on. The next section covers persisting keys. Do it after every
stateful sign call, and never sign again from an older snapshot.
Two more shrincs helpers work with commitments directly:
computePublicKeyCommitment(keys) recomputes the 32-byte commitment keys
currently implies, and recoverPublicKeyCommitment(signature) recovers the
commitment a given shrincs.sign() signature implies, like ecrecover.
Persisting and importing a SHRINCS key
Serialize keys to its 264-byte flat secret with shrincsKeysToSecretBytes
and write that to disk or a database after every stateful sign() call. To
rebuild the keypair object on restart, use shrincsImportSigningKey:
import { loadHashSigs, shrincsKeysToSecretBytes } from "@quip.network/hashsigs-wasm";
const { shrincsImportSigningKey } = await loadHashSigs();
const persisted = shrincsKeysToSecretBytes(keys); // 264 bytes, after every sign()
const restored = shrincsImportSigningKey(persisted);
shrincsImportSigningKey recomputes both roots and the commitment from the
seeds and rejects a mismatch with ERR_IMPORT_INVALID. It accepts an
already-exhausted key: stateful signing then throws
ERR_STATEFUL_LEAVES_EXHAUSTED, but stateless signing still works.
See SECURITY.md for the operational rules around holding and persisting this key material.
Object shapes
Names match ts/src/index.ts, the source of truth for the decomposed key
types:
interface SphincsPlusCKeys {
secret: { skSeed: Uint8Array; prfSeed: Uint8Array };
publicKey: { pkSeed: Uint8Array; root: Uint8Array };
}
interface ShrincsKeys {
stateless: SphincsPlusCKeys;
stateful: {
secret: { skSeed: Uint8Array; prfSeed: Uint8Array };
publicKey: { pkSeed: Uint8Array; root: Uint8Array; maxSignatures: number };
nextLeafIndex: number;
remaining: number;
};
publicKeyCommitment: Uint8Array;
}
Testing
Run all tests:
Rust currently supports the SHRINCS keccak profiles (256s, 128s-q18,
128s-q20) and the 256s-sha2 profile. The SHA-256 suite switch applies only
to SHRINCS scheme hashes (FORS-C, hypertree, WOTS-C, UXMSS); EVM-domain hashes
such as canonical action hashes and public-key commitments remain keccak to
match the Solidity design.
Run specific test vectors:
Generate SHRINCS vectors for the Solidity verifier:
Or run the generator for a specific profile:
The generator writes the profile-selected SHRINCS vector JSON inside this Rust repository:
tests/test_vectors/shrincs_sphincs_256s_keccak.json
tests/test_vectors/shrincs_sphincs_128s_q18_keccak.json
tests/test_vectors/shrincs_sphincs_128s_q20_keccak.json
tests/test_vectors/shrincs_sphincs_256s_sha2.json
SHRINCS public keys use one stateless pkSeed and one hypertreeRoot, matching
the SPHINCS+/FIPS-style PK = (PK.seed, PK.root) abstraction for the stateless
path, while the full hybrid bundle stays bound together by
public_key_commitment.
To use those vectors with the Solidity verifier tests, copy the generated file for the active profile into the Solidity repository's matching fixture path:
# example: 256s-keccak
# example: 256s-sha2
# example: 128s-q18-keccak
# example: 128s-q20-keccak
For a quick local profile-matrix sweep, run:
To cross-check Solidity-exported account vectors against the Rust verifier,
generate the account-vector JSON in hashsigs-solidity first, then copy it
into this Rust repository manually. The repos are separate, so this handoff is
intentionally not automated.
# in hashsigs-solidity
# copy the generated JSON into hashsigs-rs manually
For the shrincs-256s-sha2 profile:
# in hashsigs-solidity
FOUNDRY_PROFILE=256s-sha2-export \
# copy the generated JSON into hashsigs-rs manually
Committed Rust-side cross-check fixtures exist for every profile —
shrincs-256s-keccak, shrincs-256s-sha2, shrincs-128s-q18, and
shrincs-128s-q20 — so tests/solidity_account_vectors.rs runs on all four.
Then run the Rust-side cross-check:
Generate the kth stateful gas vector for Solidity gas benchmarks. The
generator requires Foundry's cast on PATH and writes
tests/test_vectors/shrincs_stateful_k_gas_vector.json (gitignored):
Run Solana program tests:
For test output and backtrace:
RUST_BACKTRACE=1 cargo test-sbf -- --nocapture 2>&1
Show compute units only:
RUST_BACKTRACE=1 cargo test-sbf -- --nocapture 2>&1 | grep "compute units:"
Development Requirements
- Rust 1.79 or later; the current local test pass was with Rust 1.95.0
- Solana/Agave SBF cargo subcommands, including
cargo build-sbfandcargo test-sbf, for Solana program development: https://solana.com/docs/intro/installation
NOTE: if on Mac, do not use brew to install rust and instead use https://www.rust-lang.org/tools/install
Project Structure
.
├── bin/
│ └── build-wasm.sh # cargo + wasm-bindgen helper (nodejs + web → ts/src)
├── src/
│ ├── hash/ # tagged hash suite (keccak / sha2)
│ ├── abi.rs # Solidity-compatible ABI encode/decode
│ ├── profiles.rs # compile-time parameter sets
│ ├── treehash.rs # Merkle tree hashing
│ ├── wots_c/, wotsplus/ # WOTS-C / WOTS+ primitives
│ ├── sphincs_plus_c/ # stateless SPHINCS+C scheme (fors_c, hypertree, key)
│ ├── shrincs/ # composed SHRINCS keys, signer, verifier (flat)
│ │ ├── key.rs # Keys / Commitment / PublicKey, public API
│ │ ├── signer.rs # ShrincsSigner + free sign(), public API
│ │ ├── verifier.rs # ShrincsVerifier, public API
│ │ └── uxmss.rs # stateful UXMSS half, crate-internal
│ └── wasm/ # verifier / signer wasm-bindgen surface
├── ts/ # @quip.network/hashsigs-wasm (loadShrincsWasm entry)
├── solana/ # Solana program implementation
└── tests/ # Test vectors and unit tests
SHRINCS Architecture
shrincs composes two independent schemes rather than layering shared
components:
sphincs_plus_c(src/sphincs_plus_c/) — the stateless half, used for durable recovery.uxmss(src/shrincs/uxmss.rs,pub(crate)) — the stateful half, used for the fast-path signing chain.
shrincs binds the two into a Keys and exposes them through three public
modules: key (the composed key type, the PublicKey bundle, and the
Commitment), signer (ShrincsSigner and the free sign), and verifier
(ShrincsVerifier).
Public API stability note: the stable public surface is
hashsigs_rs::shrincs::key, hashsigs_rs::shrincs::signer, and
hashsigs_rs::shrincs::verifier.
License
AGPL-3.0, see COPYING