go-qrllib
Go implementation of the Quantum Resistant Ledger (QRL) cryptographic library.

Overview
go-qrllib provides post-quantum cryptographic signature schemes — plus an
ML-KEM-1024 key-encapsulation primitive — for the QRL blockchain and
general-purpose applications requiring quantum-resistant security.
Supported Algorithms
| Algorithm |
Type |
Standard |
Use Case |
| ML-DSA-87 |
Lattice-based |
FIPS 204 |
Primary recommended algorithm |
| SPHINCS+-256s |
Hash-based |
SPHINCS+ submission (pre-FIPS 205) — see SPHINCS+ notes |
Stateless primitive; wallet path gated pending QRL's SLH-DSA parameter-set choice |
| XMSS |
Hash-based |
Pre-standardisation; see XMSS notes |
QRL v1 → v2 migration |
| ML-KEM-1024 |
Lattice-based (KEM) |
FIPS 203 |
Key-encapsulation primitive (not a signature); crypto/mlkem1024, not wallet-integrated |
CRITICAL: XMSS Statefulness Warning
XMSS is a STATEFUL signature scheme. Improper use can lead to COMPLETE
LOSS OF SECURITY.
The Risk
XMSS maintains an internal index that MUST be incremented after each
signature. If the same index is ever used twice (even for different messages),
an attacker can forge signatures for ANY message.
Requirements for Safe XMSS Usage
- Never reuse an index - Each signature MUST use a unique,
never-before-used index
- Persist state before use - To prevent OTS key reuse, the updated index
MUST be persisted to durable storage immediately after signing and BEFORE the
signature is used or broadcast
- No concurrent signing - Never sign from the same XMSS instance
concurrently
- No state rollback - Never restore an XMSS wallet from backup without
extreme care
- Index exhaustion - An XMSS tree has limited signatures (2^height); plan
for key rotation
Safe Pattern
// CORRECT: Sign, then persist updated index before using the signature
height, err := xmss.ToHeight(10)
if err != nil {
return err
}
tree, err := xmss.InitializeTree(height, xmss.SHAKE_128, seed)
if err != nil {
return err
}
signature, err := tree.Sign(message)
if err != nil {
return err
}
// CRITICAL: Persist the UPDATED index NOW. If this fails, the signature MUST NOT be used.
err = persistIndex(tree.GetIndex()) // Save to database/file
if err != nil {
return fmt.Errorf("failed to persist state (signature unsafe to use): %w", err)
}
// Only now is it safe to broadcast/use the signature
broadcast(signature)
Unsafe Patterns to Avoid
// DANGEROUS: Concurrent signing from same instance
go wallet.Sign(msg1) // Race condition - index corruption
go wallet.Sign(msg2)
// DANGEROUS: Restoring from backup
backup := loadBackup() // May have old index
wallet := restoreFromBackup(backup)
wallet.Sign(msg) // May reuse an index!
// DANGEROUS: Ignoring persistence failures
sig, _ := wallet.Sign(msg)
_ = persistState(wallet) // Ignoring error!
broadcast(sig) // Index may not be persisted
For new applications, prefer the stateless ML-DSA-87. SPHINCS+-256s remains
available
as a primitive but the QRL wallet path for SPHINCS+/SLH-DSA is intentionally
gated until
NIST and QRL settle on a final SLH-DSA parameter set; see the SPHINCS+ notes
below.
Same requirements at every API level
The persistence requirement applies identically whether you call:
crypto/xmss.XMSS.Sign — the lower-level primitive shown in the example
above, or
legacywallet/xmss.XMSSWallet.Sign — the wallet-level wrapper used to sign
for legacy QRL v1 addresses.
Both must persist the updated index (tree.GetIndex() or wallet.GetIndex()
respectively) AFTER the call returns and BEFORE the signature is used or
broadcast. The wallet wrapper is a thin delegate over the primitive — it carries
the same statefulness invariants. See the godoc on each Sign method and the
package documentation for legacywallet/xmss for
the full safe-usage pattern.
Installation
go get github.com/theQRL/go-qrllib
Requires Go 1.25 or later.
go-qrllib has zero third-party dependencies: every supporting primitive
(SHA-3/SHAKE, SHA-256, randomness, constant-time comparison) comes from the Go
standard library (crypto/sha3, crypto/sha256, crypto/rand,
crypto/subtle). go.mod has no require directives and there is no
go.sum.
Quick Start
ML-DSA-87 (Recommended)
import "github.com/theQRL/go-qrllib/crypto/ml_dsa_87"
// Generate keypair
signer, err := ml_dsa_87.New()
if err != nil {
log.Fatal(err)
}
defer signer.Zeroize() // Clear sensitive data when done
// Sign with context (required by FIPS 204)
ctx := []byte("my-application")
message := []byte("The sleeper must awaken")
signature, err := signer.Sign(ctx, message)
if err != nil {
log.Fatal(err)
}
// Verify with your own key
valid := ml_dsa_87.Verify(ctx, message, signature, signer.PublicKey())
// Verify with a key received as bytes. ParsePublicKey is the only way to
// turn bytes into a key Verify accepts; it checks the length and rejects
// a weak key (see ValidatePublicKey), a shape key generation never produces.
pkBytes := signer.PublicKey().Bytes() // stand-in for the 2592 bytes you received
pk, err := ml_dsa_87.ParsePublicKey(pkBytes[:])
if err != nil {
log.Fatal(err) // ErrInvalidPublicKey or ErrWeakPublicKey
}
valid = ml_dsa_87.Verify(ctx, message, signature, pk)
SPHINCS+-256s (primitive; wallet path gated)
The example below uses the raw SPHINCS+-256s primitive directly. The QRL
wallet layer for SPHINCS+/SLH-DSA is intentionally not currently issuable:
the implementation here is the SPHINCS+ submission (pre-FIPS 205), and QRL
has not yet committed to one of the twelve SLH-DSA parameter sets FIPS 205
specifies, so activating the wallet path now would commit users to a choice
QRL has not made. See the SPHINCS+ notes in Standards
Compliance.
import "github.com/theQRL/go-qrllib/crypto/sphincsplus_256s"
signer, err := sphincsplus_256s.New()
if err != nil {
log.Fatal(err)
}
defer signer.Zeroize()
message := []byte("The sleeper must awaken")
signature, err := signer.Sign(message)
if err != nil {
log.Fatal(err)
}
pk := signer.GetPK()
valid := sphincsplus_256s.Verify(message, signature, &pk)
Wallet Layer (QRL V2.0)
The wallet packages wrap the crypto primitives with QRL-specific address
derivation, a canonical descriptor, and a domain-separated signing context that
cryptographically binds every signature to its wallet descriptor (see package
docs for details).
import "github.com/theQRL/go-qrllib/wallet/ml_dsa_87"
// Create a fresh wallet, or restore from a mnemonic / extended seed.
w, err := ml_dsa_87.NewWallet()
// w, err := ml_dsa_87.NewWalletFromMnemonic(phrase)
// w, err := ml_dsa_87.NewWalletFromHexExtendedSeed(hexSeed)
if err != nil {
log.Fatal(err)
}
defer w.Zeroize()
address := w.GetAddressStr() // "Q" + hex(64 bytes)
pk := w.GetPK()
desc := w.GetDescriptor().ToDescriptor()
sig, err := w.Sign(message)
if err != nil {
log.Fatal(err)
}
ok := ml_dsa_87.Verify(message, sig[:], &pk, desc)
The same API shape is available at
github.com/theQRL/go-qrllib/wallet/sphincsplus_256s,
but note that the QRL wallet layer currently treats SPHINCS+/SLH-DSA as
non-issuable
(it remains verifiable, so existing addresses keep working): wallet creation
under that
type is gated until the QRL-adopted SLH-DSA parameter set is finalised. See the
SPHINCS+
notes below and wallet/common/wallettype/type.go for the IsIssuable /
IsVerifiable
split.
crypto.Signer Interface (ML-DSA-87)
ML-DSA-87 implements Go's crypto.Signer interface for interoperability with
crypto/tls, crypto/x509, and other standard library consumers:
import "github.com/theQRL/go-qrllib/crypto/ml_dsa_87"
d, err := ml_dsa_87.New()
if err != nil {
log.Fatal(err)
}
defer d.Zeroize()
signer := ml_dsa_87.NewCryptoSigner(d)
// signer satisfies crypto.Signer
// Sign with FIPS 204 context via SignerOpts
sig, err := signer.Sign(nil, message, &ml_dsa_87.SignerOpts{
Context: []byte("my-application"),
})
The opts parameter must be *ml_dsa_87.SignerOpts or nil (empty context).
Passing other crypto.SignerOpts types (e.g., crypto.SHA256) returns an
error.
QRL v2 addresses are displayed as a "Q" prefix followed by 128 hex characters
(64-byte address, 129 characters total). Two string forms are produced:
| Helper |
Output |
When to use |
common.ToChecksumAddress(addr) / wallet.GetChecksumAddressStr() |
Canonical EIP-55-style mixed-case (e.g. QaFAE844Fa3bE904799cCdB74...) |
User-facing displays where transcription-error detection is desirable |
(w *Wallet) GetAddressStr() / fmt.Sprintf("Q%x", addr) |
Lowercase hex |
Backward-compatible canonical form; safe for string comparison |
Validation:
common.IsValidAddress(s) — permissive: accepts all-lowercase, all-uppercase,
or correctly-checksummed mixed-case. Mixed-case with a bad checksum is
rejected.
common.IsValidChecksumAddress(s) — strict: only true when s matches the
canonical checksummed form character-for-character (uppercase "Q" required;
uniform-case forms containing letters return false).
Both validators apply the exact 129-character rule to the string as given. No
input normalization is performed, so surrounding whitespace and a lowercase
"q" prefix are rejected. Trimming hand-entered text is an input-layer
concern; pass the normalized string in. Front ends built on @theqrl/wallet.js
accept a documented superset here — the emitted forms are identical.
Algorithm: the case-selection nibbles are drawn from SHAKE-256 of the
UTF-8 bytes of the 128-character lowercase hex body (no Q prefix), with
dkLen = AddressSize, giving exactly one nibble per hex character. For each
a-f letter, uppercase iff the corresponding nibble is ≥ 8. Identical
across @theqrl/wallet.js, go-qrllib, and rust-qrllib.
Extended Seed Text Form
(w *Wallet) GetHexSeed() and common.ExtendedSeed.ToHex() emit the canonical
form: lowercase "0x" followed by 102 lowercase hex characters (104 characters
total). Every hex extended-seed entry point in the library accepts that form
unchanged — common.NewExtendedSeedFromHexString and each wallet package's
NewWalletFromHexExtendedSeed. An optional "0x"/"0X" prefix is removed
before the exact-length check, and uppercase hex is accepted; whitespace and
separator characters are not. No input normalization beyond the prefix is
performed; trimming hand-entered text is an input-layer concern.
Descriptor validity is a separate check applied after decoding, so
common.NewExtendedSeedFromHexString still rejects a SPHINCS+ extended seed —
that type is not a valid common descriptor until SLH-DSA activation.
Thread Safety
| Type |
Thread-Safe? |
Notes |
ml_dsa_87.MLDSA87 |
Read: Yes, Write: No |
Safe to call GetPK(), PublicKey(), Verify() concurrently. Do not call Sign() concurrently on same instance. |
sphincsplus_256s.SphincsPlus256s |
Read: Yes, Write: No |
Same as ML-DSA-87 |
xmss.XMSS |
No |
NEVER use concurrently. Index management is not thread-safe. |
Package-level Verify() |
Yes |
Stateless, safe to call concurrently |
Safe Concurrent Pattern
// Create separate instances for concurrent signing
func signConcurrently(messages [][]byte, seed [32]byte) {
var wg sync.WaitGroup
for _, msg := range messages {
wg.Add(1)
go func(m []byte) {
defer wg.Done()
// Create NEW instance for each goroutine
signer, _ := ml_dsa_87.NewMLDSA87FromSeed(seed)
defer signer.Zeroize()
signer.Sign(ctx, m)
}(msg)
}
wg.Wait()
}
Algorithm Selection Guide
| Requirement |
Recommended Algorithm |
| General purpose, best performance |
ML-DSA-87 |
| Maximum security, don't trust lattice assumptions |
SPHINCS+-256s primitive (wallet path gated, see notes) |
| QRL blockchain transactions |
ML-DSA-87 (via wallet layer) |
| Legacy QRL address compatibility |
XMSS (with extreme care) |
| Signatures must be deterministic (e.g. RANDAO-style verifiable beacon contributions) |
ML-DSA-87 via the SignDeterministic(ctx, msg) opt-in helper (FIPS 204 deterministic mode) — Sign itself is hedged by default as per FIPS 204. |
Key Sizes
| Algorithm |
Public Key |
Secret Key |
Signature |
| ML-DSA-87 |
2,592 bytes |
4,896 bytes |
4,627 bytes |
| SPHINCS+-256s |
64 bytes |
128 bytes |
29,792 bytes |
| XMSS (h=10) |
64 bytes |
~2,500 bytes |
~2,500 bytes |
NIST ACVP Verification
ML-DSA-87 key generation and signing are verified against official NIST ACVP
test vectors. These tests run
automatically in CI and are guarded by a build tag so they don't run during
normal go test ./....
ML-KEM-1024 key generation, encapsulation, and decapsulation — including the
encapsulation- and decapsulation-key validity checks — are likewise verified
against NIST ACVP vectors. These run inline with go test ./... (see
crypto/internal/mlkem1024/acvp_test.go).
To run them locally, see .github/acvp/README.md.
Standards Compliance
- ML-DSA-87: FIPS 204 (Module-Lattice-Based Digital Signature Standard)
- SPHINCS+-256s (notes): The implementation in this library is the
SPHINCS+
submission (pre-FIPS 205), specifically
SHAKE-256s-robust. NIST published
SLH-DSA (FIPS 205) in August 2024
as
the standardised successor; FIPS 205 differs from the SPHINCS+ submission in
parameter-set details. The QRL wallet layer does not currently issue new
SPHINCS+/SLH-DSA wallets: the wallet type is retained as a reserved
constant,
but common wallet descriptor/type validation rejects it until QRL settles on a
specific SLH-DSA parameter set and the implementation is updated to match it.
Existing
SPHINCS+-256s primitive use (the crypto/sphincsplus_256s package, outside
the
wallet layer) remains supported with the caveat that the parameter set may
change once SLH-DSA finalises for QRL. For new wallets, use ML-DSA-87.
- XMSS: This library's XMSS implementation predates RFC 8391
(published August 2018) and was built to support the QRL v1 blockchain at
launch. It is not intended as a general RFC-compliant XMSS implementation;
its role here is to keep v1 mainnet addresses parseable, verifiable, and
signable during the v1 → v2 migration. Where parameter-set choices happen to
overlap with RFC 8391 (XMSS-SHA2_10_256 and XMSS-SHAKE_256_10_256), signatures
produced by go-qrllib verify under the RFC 8391 reference implementation, and
reference signatures verify under go-qrllib via the
crypto/xmss/rfc8391 sub-package. This is exercised
bidirectionally in CI by
.github/cross-verify/,
pinned to the original RFC 8391 reference. The library does not track later
standards updates such as NIST SP 800-208 (October 2020), which refined
expand_seed to take additional inputs. Adopting that refinement would
change the keypair derived from any given seed and break compatibility with
existing v1 mainnet addresses, so it is intentionally not applied here. New
signature issuance on QRL uses ML-DSA-87 (FIPS 204).
SHAKE_128 is a pre-standardisation QRL-specific hash variant, retained
for v1 mainnet address compatibility only. See
SECURITY.md for the full provenance
discussion.
- ML-KEM-1024: FIPS 203 (Module-Lattice-Based Key-Encapsulation Mechanism).
Provided as a
key-establishment primitive in
crypto/mlkem1024; it is not a
signature scheme and is
not integrated into the QRL wallet or address layer. The implementation
tracks Go's
FIPS-validated crypto/mlkem standard-library code and is verified against
the NIST ACVP
ML-KEM vectors (key generation, encapsulation, decapsulation, and key-check
cases).
Security Considerations
- Zeroize sensitive data - Always call
Zeroize() when done with a signer
- Use crypto/rand - Never use weak random sources for key generation
- Context separation - Use unique contexts for different applications
(ML-DSA-87)
- XMSS state - See critical warning above
- Side channels - Signing and verification use branchless arithmetic and
constant-time comparisons; see SECURITY.md for precise
boundaries
See SECURITY.md for detailed security information and threat
model.
License
This project is licensed under the MIT License - see the LICENSE file
for details.
Contributing
Contributions are welcome! Please ensure:
- All tests pass (
make test or go test ./...)
- Note: SPHINCS+ wallet tests are slow (~3-4 minutes); use
make test-fast for
quicker iteration.
- Code is linted (
make lint)
- Markdown is linted (
make markdownlint; make markdownlint-fix handles the
auto-fixable findings)
- No new security vulnerabilities (
make govulncheck)
To install development tools (golangci-lint, govulncheck):
make tools