If you discover a security vulnerability in go-qrllib, please report it responsibly:
- Do NOT open a public issue
- Email security concerns to security@theqrl.org
- Or report via https://www.theqrl.org/security-report/
- Include detailed steps to reproduce
- Allow reasonable time for a fix before public disclosure
This library assumes:
- Trusted execution environment - The code runs on a system not compromised by malware
- Secure random source -
crypto/randprovides cryptographically secure randomness - No physical access attacks - Attacker cannot probe hardware or extract memory
- Correct usage - Caller follows documented usage patterns (especially for XMSS)
| Threat | Protection |
|---|---|
| Quantum computer attacks on signatures | Post-quantum algorithms (ML-DSA, SPHINCS+, XMSS) |
| Signature forgery | Cryptographic hardness assumptions |
| Timing side-channels in verification | All verification uses constant-time comparison (subtle.ConstantTimeCompare) |
| Key material in memory after use | Zeroize() methods |
| Non-canonical signature acceptance | Strict signature validation |
| Threat | Mitigation |
|---|---|
| Compromised system/malware | Use hardware security modules |
| Side-channel attacks via Go runtime | GC, compiler optimisations, and goroutine scheduling may introduce timing variation outside this library's control |
| XMSS index reuse | Caller must manage state correctly |
| Weak random number generation | Ensure crypto/rand works correctly |
| Memory not being zeroed by GC | Go limitation; use with HSM for high security |
| Brute-force verification attempts | Implement rate limiting at application layer |
| Property | Status |
|---|---|
| Post-quantum secure | Yes (Module-LWE assumption) |
| EUF-CMA secure | Yes |
| Deterministic signing | Hedged by default as per FIPS 204: each call mixes fresh crypto/rand randomness into the per-signature RND_BYTES, so two calls with the same (key, ctx, message) produce distinct signatures (both verify under the same public key). FIPS 204 deterministic mode (rnd = 32 zero bytes) is available as an explicit opt-in via MLDSA87.SignDeterministic(ctx, msg) for protocols where determinism is itself a requirement (RANDAO-style verifiable beacon contributions, test-vector reproduction). Equivalent at the wire level to crypto.Signer.Sign(zeroReader, ...) — both paths route into the same internal entry point. |
crypto.Signer.Sign rand handling |
The caller-supplied io.Reader is honoured: non-nil → its bytes drive RND_BYTES; nil → crypto/rand is used. |
| Stateless | Yes |
| Side-channel resistant | Branchless arithmetic in signing path; see details below |
| Signature malleability | No (canonical encoding enforced) |
Security Level: NIST Level 5 (equivalent to AES-256)
| Property | Status |
|---|---|
| Post-quantum secure | Yes (hash function security) |
| EUF-CMA secure | Yes |
| Deterministic signing | Optional (randomized by default) |
| Stateless | Yes |
| Side-channel resistant | Hash-based, inherently resistant |
| Signature malleability | No (hash-based, canonical) |
Security Level: NIST Level 5
| Property | Status |
|---|---|
| Post-quantum secure | Yes (hash function security) |
| EUF-CMA secure | Yes, IF index never reused |
| Deterministic signing | Yes |
| Stateless | NO - STATEFUL |
| Side-channel resistant | Hash-based, inherently resistant |
| Signature malleability | No (hash-based, canonical) |
Security Level: Configurable based on parameters
CRITICAL: XMSS security is COMPLETELY BROKEN if the same index is used twice.
The XMSS implementation in go-qrllib exists for one purpose: to
keep QRL v1 mainnet addresses parseable, verifiable, and (for their
owners) signable during the ongoing migration to ML-DSA-87
(FIPS 204). New signature issuance on QRL uses ML-DSA-87. XMSS in
this library is not intended as a standards-tracking implementation
and is not recommended for new wallets.
The library exposes three hash-function options:
| HashFunction | Status |
|---|---|
SHA2_256 |
XMSS-SHA2_*_256 family — RFC 8391 (Aug 2018) signature format. See "Standards alignment" below for the relationship to SP 800-208. |
SHAKE_256 |
XMSS-SHAKE_*_256 family — RFC 8391 (Aug 2018) signature format. See "Standards alignment" below for the relationship to SP 800-208. |
SHAKE_128 |
QRL-specific extension, retained for legacy compatibility from QRL's pre-standardisation XMSS implementation. Not part of NIST SP 800-208. With a 32-byte output it offers approximately 64-bit quantum security under a Grover-style attack — theoretically reduced relative to SHAKE_256 / SHA2_256 (~128-bit quantum). Not recommended for new wallets. Existing v1 mainnet addresses minted under SHAKE_128 must continue to be parseable, verifiable and signable, which is the only reason this option survives. |
Signatures produced by go-qrllib for the XMSS-SHA2_10_256 parameter
set match the RFC 8391 (August 2018) signature format and verify under
the reference implementation (see .github/cross-verify/README.md).
The keypair-derivation surface diverges from the reference in two
distinct ways:
-
Layout conventions: the 48-byte QRL seed is SHAKE-256-expanded into the 96 bytes (SK_SEED || SK_PRF || PUB_SEED) the reference takes directly, and the QRL public key is prefixed with a 3-byte QRL descriptor rather than a 4-byte RFC 8391 OID. The
crypto/xmss/rfc8391sub-package exposes a 96-byte direct-seed entry point and OID-form public-key marshalling that bridge both conventions, and is what the bidirectional cross-verify CI uses on the go-qrllib side. -
expand_seedconstruction: the WOTS+ secret-key derivation follows the original RFC 8391 (Aug 2018), not the NIST SP 800-208 (Oct 2020) refinement. This is because QRL's XMSS implementation predates both standards and the v1 mainnet keypair derivation depends on the original construction; adopting the SP 800-208 refinement would alter the keypair derived from any given v1 seed and is therefore not appropriate for a deployed scheme. This is an algorithmic difference and is not something therfc8391sub-package can bridge and the sub-package addresses layout conventions only. The cross-verify CI accommodates the difference by pinningxmss-referenceto commit7793c40(the last revision on the original construction) so the bidirectional check aligns with the construction this library targets. See "Standards alignment" below for the deeper discussion.
The implementation predates RFC 8391 (August 2018) and follows the
original RFC 8391 expand_seed construction. It does not track later
refinements such as NIST SP 800-208 (October 2020), which adjusted
expand_seed to take additional inputs. Applying that refinement
would alter the keypair derived from any given v1 seed and is
therefore not appropriate here. The cross-implementation verification
CI in .github/cross-verify/ pins xmss-reference to commit
7793c40 (the last revision on the original spec) so the
bidirectional cross-verify aligns with the construction this library
targets.
ML-KEM-1024 is the NIST Module-Lattice-Based Key-Encapsulation Mechanism (FIPS 203). It is provided as a key-establishment primitive in crypto/mlkem1024; it is not a signature scheme and is not currently part of the QRL wallet or address layer.
- Provenance: a restructured port of Go's FIPS-validated
crypto/mlkem(standard library), with performance-oriented internal changes (lazy NTT reduction, fused base multiply, hand-unrolled bit-packing) verified bit-for-bit against the standard library and against the NIST ACVP vectors (KeyGen, Encapsulation, Decapsulation, and encapsulation/decapsulation key-check cases). - IND-CCA2 / implicit rejection: decapsulation uses the Fujisaki–Okamoto transform with a constant-time re-encryption comparison (
crypto/subtle); on a ciphertext mismatch it returns a pseudorandom shared secret derived from the secret rejection valuez(SHAKE256(z‖ct)) — never an error and never the real key — with no secret-dependent branching. - Input validation: encapsulation keys, decapsulation-key seeds, and ciphertexts are length-checked at the API boundary and return typed errors (never panic). Decoded encapsulation-key coefficients are rejected if any is ≥ q, preventing acceptance of malformed keys.
- Zeroization:
DecapsulationKey.Zeroize()wipes the secret seeds (d,z) and the secret vectors; the encapsulation/decapsulation paths additionally wipe the transient decrypted message and FO hash buffers on a best-effort basis (see Key Zeroization). - Randomness: key generation and encapsulation draw from
crypto/rand; a failure of the system entropy source surfaces as an error rather than silently producing low-entropy keys.
QRL v2.0 addresses are 64 bytes, derived as:
Address = SHAKE256(Descriptor || PK)[:64]
The 64-byte (512-bit) address exceeds NIST Category 5 post-quantum collision resistance. NIST Category 5 targets 256-bit classical / 128-bit quantum security; the previous 48-byte (384-bit) size met that target and the 64-byte size provides additional headroom so the address never becomes the weakest link in the security chain (the underlying signature schemes — ML-DSA-87 and SPHINCS+-256s — both target NIST Level 5).
String form: "Q" + hex(address) = 129 characters.
QRL v2 addresses support an EIP-55-style mixed-case checksum to detect
transcription errors. The scheme is identical across @theqrl/wallet.js,
go-qrllib, and rust-qrllib: 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 (64 bytes = 128 nibbles). For
each hex character: if it is a letter (a-f) and the corresponding nibble
is ≥ 8, it is uppercased; otherwise it stays lowercase. The "Q" prefix
is always uppercase on output and is not part of the checksum input.
| Helper | Behavior |
|---|---|
common.ToChecksumAddress(addr) |
Emits the canonical checksummed mixed-case form. |
(w *Wallet) GetChecksumAddressStr() |
Convenience wrapper on the wallet. |
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: returns true only for the exact canonical form (uppercase Q, hex body case-for-case identical to ToChecksumAddress output). |
This is a typo-detection layer, not an authentication mechanism. An attacker
who controls the address-display path can always show a correctly-checksummed
address of their choosing. The lowercase form remains a valid encoding;
applications that want stronger guarantees against transcription errors should
require checksummed input via IsValidChecksumAddress.
A digit substitution is not detected (digits carry no checksum information); a case flip on a hex letter is detected with probability ≈ 15/16 per affected letter.
Constant-time (no data-dependent branches):
- Signature verification: challenge comparison via
subtle.ConstantTimeCompare(ML-DSA-87, SPHINCS+) - NTT/inverse NTT: fixed loop bounds, no data-dependent branches
- Polynomial norm checks: branchless violation-flag accumulator: iterates all N coefficients regardless of outcome, sign extraction via arithmetic shift (
polyChkNormin ML-DSA-87) - Field decomposition:
Power2Round,Decompose,MakeHint,UseHintall use mask-based conditional selection with no branches on coefficient values (seecrypto/internal/lattice/rounding.go) - Public key equality:
CryptoPublicKey.Equalusessubtle.ConstantTimeCompare
Inherently variable-time (not secret-dependent):
- Rejection sampling loop in signing: the number of iterations before a valid signature is found varies per attempt. FIPS 204 Appendix C acknowledges this is not a side-channel concern: the rejection probability depends on public parameters and random nonces, not on the secret key
- SHAKE-256 / SHA-3 operations: assumed constant-time for a given input length (provided by the Go standard library's
crypto/sha3)
Go runtime caveats:
The above properties describe the library's own code. The Go runtime may introduce timing variation through garbage collection pauses, goroutine scheduling, and compiler optimisations (e.g., bounds-check elimination may alter branch patterns). These are outside this library's control. For environments where hardware-level constant-time guarantees are required, use a hardware security module.
All signature schemes enforce canonical encoding, verified by comprehensive negative tests:
ML-DSA-87:
- Hint indices stored in strictly increasing order
- z coefficients bounded by
GAMMA1 - BETA - Zero padding enforced in hint section
- Cumulative counts must be non-decreasing and ≤ OMEGA
SPHINCS+ / XMSS:
- Hash-based signatures are inherently canonical
- Fixed signature sizes enforced
Non-canonical encodings are rejected by the verification functions. This is verified by:
| Test File | Coverage |
|---|---|
crypto/ml_dsa_87/canonicality_test.go |
Truncation, hint ordering, padding, cumulative counts |
crypto/sphincsplus_256s/canonicality_test.go |
Truncation, FORS/WOTS/auth path corruption |
crypto/xmss/canonicality_test.go |
Truncation, index/R/WOTS/auth path corruption, height validation |
Run canonicality tests:
go test -v -run TestCanonicality ./crypto/...All crypto types implement Zeroize() to clear the secret-key and seed
fields of the instance. Internally these route through the package's
shared zeroBytes helper so the wipe behaviour is defined in one place:
func (d *MLDSA87) Zeroize() {
zeroBytes(d.sk[:])
zeroBytes(d.seed[:])
}zeroBytes overwrites the slice in place and uses runtime.KeepAlive
to prevent the compiler from eliding the writes as a dead store.
In addition to instance-level Zeroize(), both signing AND key-generation paths
automatically zeroise their secret temporaries via deferred cleanup
when they return. This reduces the window for secret intermediates
persisting in freed memory:
- ML-DSA-87 signing (
cryptoSignSignatureInternal):key,rhoPrime,s1,s2,t0 - ML-DSA-87 key generation (
cryptoSignKeypair):key,rhoPrime,s1,s1hat,s2,t0 - ML-DSA-87 hex-seed parsing (
NewMLDSA87FromHexSeed): the heap-allocatedunsizedSeedbyte slice and the temporary fixed-size seed array - ML-DSA-87 constructors (
New,NewMLDSA87FromSeed): the constructor-localsk/seedarray copies are wiped once the returned instance owns them - ML-DSA-87 SHAKE pool: pooled SHAKE256 states are
Reset()before being returned to the pool, so sponge state that absorbed secret material never lingers between uses - ML-KEM-1024 K-PKE internals (
pkeKeyGen/pkeEncrypt/pkeDecrypt): seed/noise/message-derived polynomial intermediates (gInput,G,e,y,e1,e2,mu,v,acc) are wiped data-independently - SPHINCS+:
ctx.SkSeed
Zeroisation in this library is best-effort, not absolute. Go's
runtime may copy values during garbage collection, escape analysis,
slice growth, or interface boxing; any such copy that occurred
before the zeroisation executes is outside the library's control
and remains in memory until that copy is itself overwritten or
reclaimed. The runtime.KeepAlive calls in zeroBytes and the
zeroPoly family defeat dead-store elimination by the compiler
for the explicit overwrite, but they do not address
runtime-side duplication of the underlying data.
What this means in practice:
- Calling
Zeroize(or letting a signing-path defer fire) closes the obvious window where secret material sits in process memory after it has finished being used. This is a useful defence-in-depth measure for short-lived signers and against memory-disclosure bugs in the host process. - It does NOT guarantee that no copy of the secret survives
anywhere in the address space. Workloads with adversaries that
have physical or kernel-level memory access (cold-boot attacks,
/proc/<pid>/mem, hibernation images, swap files) need a hardware security module for hard guarantees.
For highest security, combine in-library zeroisation with HSM-backed
key storage, locked memory pages (mlock/VirtualLock), and
swap-disabled hosts.
The XMSS index MUST be persisted to durable storage before using any signature:
1. Generate signature (index auto-increments)
2. Persist new index to durable storage
3. Verify persistence succeeded
4. Only then use/broadcast the signature
| Scenario | Risk | Mitigation |
|---|---|---|
| Power loss during signing | Index may not persist | Persist before using signature |
| Concurrent signing | Race condition on index | Never sign concurrently |
| Backup restoration | May reuse old index | Track "high water mark" separately |
| Database rollback | May reuse indices | Use append-only index storage |
For production XMSS usage:
- Use append-only storage for index tracking
- Implement "high water mark" that can never decrease
- Log all signing operations for audit
- Monitor remaining signatures (2^height limit)
- Plan key rotation before exhaustion
Every public verification and "open" function in the library treats malformed inputs as a refusal, never as a panic. The following preconditions are checked at the API boundary:
The library distinguishes two failure classes:
- Malformed user input: nil pointers, wrong-size buffers, unsupported parameter values supplied through the public API. Always surfaces as a typed error or a
false/nilreturn; never panics. The tables below enumerate the specific cases. - Invariant violations: internal preconditions that should never fail if the rest of the library is correct (e.g. an unrecognised
HashFunctionreaching the dispatch switch after the public constructor's validation guard, orxmss/params.go'slogWswitch reaching its impossible default). These panic with a clear message; they exist as crash-early tripwires so that any future regression which bypasses an upstream guard fails loudly in tests rather than silently corrupting key material in production.
Existing invariant-panic sites include crypto/xmss/params.go (WOTS parameter values), crypto/xmss/hash.go:coreHash (HashFunction dispatch), crypto/xmss/xmss_fast.go:treeHashSetup (Height bounds), and several SHAKE I/O sites in crypto/sphincsplus_256s/hash_shake.go (cryptographic-primitive errors that the SHA-3 contract guarantees do not occur). All carry comments explaining what upstream invariant is being enforced.
| Function | Nil public key | Wrong-size signature | Oversized context | Verification failure |
|---|---|---|---|---|
crypto/ml_dsa_87.Verify |
returns false |
returns false |
returns false |
returns false |
crypto/ml_dsa_87.Open |
(nil, ErrPublicKeyNil) |
(nil, ErrInvalidSignatureSize) |
(nil, ErrInvalidContext) |
(nil, ErrInvalidSignature) |
crypto/sphincsplus_256s.Verify |
returns false |
returns false |
n/a | returns false |
crypto/sphincsplus_256s.Open |
(nil, ErrPublicKeyNil) |
(nil, ErrInvalidSignatureSize) |
n/a | (nil, ErrInvalidSignature) |
crypto/xmss.Verify |
n/a (slice; len-checked) | returns false |
n/a | returns false |
legacywallet/xmss.Verify |
n/a (value type) | returns false |
n/a | returns false |
wallet/ml_dsa_87.Verify |
returns false |
returns false |
n/a | returns false |
wallet/sphincsplus_256s.Verify |
returns false |
returns false |
n/a | returns false |
The crypto-level Open functions return ([]byte, error). Each failure mode surfaces a distinct typed sentinel from cryptoerrors, so callers that need to log or route on specific failure types can use errors.Is(err, cryptoerrors.ErrPublicKeyNil) etc. Callers that don't care which failure occurred can write msg, _ := Open(...) and treat msg == nil as "did not verify".
Internal entry points (cryptoSignVerify, cryptoSignOpen) carry the same nil-PK guard as defense-in-depth and surface the same typed sentinels.
Regression tests in each affected package (nil_pk_test.go) exercise the nil-pk path with a recover-and-fail-on-panic harness so a future edit that removes the guard fails CI immediately.
The typed errors returned by Open describe (a) the caller's own input shape — nil pointer, oversized context, undersized signature buffer — or (b) the boolean verification outcome. None is computed from secret material, so no error path constitutes a verification oracle that could help an attacker forge signatures: the underlying ML-DSA / SPHINCS+ schemes are EUF-CMA secure, and an attacker with unlimited Verify queries learns nothing useful from a "valid input shape, invalid signature" error that they would not learn from Verify returning false.
The fast-fail vs slow-fail timing distinction (early input-shape errors vs full verification then ErrInvalidSignature) reveals only input-shape information that the attacker supplied themselves. Within the slow-fail path the constant-time-comparison properties documented above ensure no further timing leak based on secret state.
Callers forwarding these errors to untrusted clients should follow standard Go server-side practice of mapping internal errors to coarser external messages.
XMSS constructors additionally validate parameter-set identifiers at the API boundary. The contract is: every exported XMSS constructor MUST call HashFunction.IsValid() and Height.IsValid() on its caller-supplied values before deriving any key material; an invalid value MUST surface as a typed error (cryptoerrors.ErrInvalidHashFunction or cryptoerrors.ErrInvalidHeight) rather than producing a degenerate zero-rooted XMSS at signing time.
Raw-seed constructors (InitializeTree, XMSSFastGenKeyPair)
additionally enforce len(seed) == 48 (xmss.SeedSize), returning
cryptoerrors.ErrInvalidSeed otherwise — any other length would be
silently SHAKE256-expanded into a working tree carrying less entropy
than the caller believes. Regression tests live in
crypto/xmss/seed_validation_test.go.
| Constructor | HashFunction validated | Height validated |
|---|---|---|
crypto/xmss.InitializeTree |
yes | yes |
crypto/xmss.XMSSFastGenKeyPair / XMSSFastGenKeyPairFromExpandedSeed |
yes | yes |
legacywallet/xmss.NewWalletFromSeed |
yes (defence-in-depth) | yes (existing height > MaxHeight check) |
legacywallet/xmss.NewWalletFromExtendedSeed |
yes (via descriptor parser → xmss.ToHashFunction) |
yes (same path) |
legacywallet/xmss.NewWalletFromHeight |
yes (delegates to NewWalletFromSeed) |
yes (same) |
Two internal defence-in-depth tripwires back the contract:
crypto/xmss.coreHashdispatchswitchcarries adefault:panic that fires immediately if any future regression lets an invalidHashFunctionreach the hash function with no upstream guard. Without this default case the buffer would be left zero-initialised (the original audit defect).crypto/xmss.InitializeTreepost-construction non-zero-root check, which asserts the constructed Merkle root is not all-zero before returning the XMSS. Catches any other future regression in the key-derivation pipeline that produces an unconstructed root.
Regression tests live in crypto/xmss/hash_function_validation_test.go and legacywallet/xmss/hash_function_validation_test.go; together they cover every invalid uint8 value cast to HashFunction, the coreHash tripwire, and a positive cross-seed-distinct-roots invariant for each valid hash function.
None. go-qrllib has zero third-party dependencies: all cryptographic
primitives come from the Go standard library (crypto/sha3, crypto/rand,
crypto/subtle, crypto/sha256). go.mod contains no require
directives and there is no go.sum.
- Zero third-party modules — the supply-chain surface is the Go toolchain itself
go mod verifyruns in CI to detect tampering- Dependabot monitors GitHub Actions (and any future module) updates
- Sigstore attestations for all release artifacts
- SLSA Level 3 provenance for build verification
- SBOM published in SPDX and CycloneDX formats
All releases include cryptographic attestations and checksums for verification.
# Verify attestation for go.mod (go.sum no longer exists — zero dependencies)
gh attestation verify go.mod --owner theQRL
# Verify SBOM attestation
gh attestation verify sbom-spdx.json --owner theQRLDownload and verify checksums from the release:
# Download checksums file
curl -LO https://github.com/theQRL/go-qrllib/releases/download/vX.Y.Z/checksums-sha256.txt
# Verify go.mod
sha256sum -c checksums-sha256.txt# Install slsa-verifier
go install github.com/slsa-framework/slsa-verifier/v2/cli/slsa-verifier@latest
# Download provenance
curl -LO https://github.com/theQRL/go-qrllib/releases/download/vX.Y.Z/provenance.intoto.jsonl
# Verify provenance
slsa-verifier verify-artifact go.mod \
--provenance-path provenance.intoto.jsonl \
--source-uri github.com/theQRL/go-qrllibEach release includes SBOMs in two formats:
- SPDX:
sbom-spdx.json - CycloneDX:
sbom-cyclonedx.json
These can be analyzed with tools like:
# Using grype for vulnerability scanning
grype sbom:sbom-spdx.json
# Using syft for inspection
syft convert sbom-cyclonedx.json -o table| Artifact | Attestation Type | Purpose |
|---|---|---|
go.mod |
Build provenance | Verify module definition (no dependencies, so no go.sum) |
checksums-sha256.txt |
Build provenance | Integrity verification |
sbom-spdx.json |
SBOM | Software composition |
sbom-cyclonedx.json |
SBOM | Software composition |
| Source code | SLSA provenance | Build reproducibility |
Attestations are signed using GitHub's Sigstore integration:
- Identity: GitHub Actions OIDC token
- Transparency: Logged in Sigstore's Rekor transparency log
- Verification: Proves release came from official CI workflow
This provides equivalent (or stronger) guarantees than GPG-signed tags:
- No key management or distribution required
- Tied to repository's GitHub Actions identity
- Publicly auditable via transparency logs
- golangci-lint enforces code quality
- go test -race detects race conditions
- govulncheck scans for known vulnerabilities
- Fuzz testing for parsing robustness
All changes to cryptographic code require:
- Code review by maintainers
- Passing CI (lint, test, vulncheck)
- No new security warnings
| Version | Supported |
|---|---|
| Latest | Yes |
| Previous minor | Security fixes only |
| Older | No |
For security concerns, contact security@theqrl.org.