Cryptography
This page specifies every cryptographic primitive Qubits uses: the two signature schemes, their identifiers and encodings, the domains that bind a signature to a purpose, each BLAKE3 hash context, the encrypted key file, the vectors that pin verification, measured timings, and the signature cache. It is written for protocol engineers and auditors. Unless a section says otherwise, the values here are part of the on-chain format, and changing one changes which blocks are valid.
Primitives
| Purpose | Primitive | Crate | Requirement | Locked version |
|---|---|---|---|---|
| Default and validator signatures | ML-DSA-65, FIPS 204 | fips204 | =0.5.0 | 0.5.0 |
| Recovery and cold-key signatures | SLH-DSA-SHA2-192s, FIPS 205 | fips205 | =0.5.0 | 0.5.0 |
| Every hash | BLAKE3 in derive-key mode, 256 bit | blake3 | 1.8 | 1.8.7 |
| Address text | bech32m | bech32 | 0.11 | 0.11.1 |
| On-chain encoding | borsh | borsh | 1 | 1.8.1 |
| Key file key derivation | Argon2id, version 0x13 | argon2 | 0.5 | 0.5.3 |
| Key file encryption | XChaCha20-Poly1305 | chacha20poly1305 | 0.10 | 0.10.1 |
Validator links use TLS 1.3 with a hybrid key exchange, which Validator networking describes. qubits-crypto and qubits-types both declare #![forbid(unsafe_code)].
Why two schemes
ML-DSA-65 is the default and the only scheme validators may use. SLH-DSA-SHA2-192s is for recovery and cold keys. The design doc keeps both because they rest on different mathematical assumptions: ML-DSA on module lattices, SLH-DSA on hash functions alone. A break of one leaves the other standing, so the chain does not reduce to a single scheme. SLH-DSA-SHA2-192s is NIST level 3, the same level as ML-DSA-65, so a recovery key is not weaker than the key it protects.
Why the crate versions are pinned
fips204 and fips205 are pinned to exact versions. The comment in Cargo.toml gives the reason: "what these accept decides which blocks are valid". A verifier that accepts one more signature, or one fewer, is a fork, so these crates are upgraded only together with the vectors in Test vectors. Features are minimal in both: default-features = false, default-rng, and only the parameter set in use (ml-dsa-65, slh-dsa-sha2-192s).
The design doc lists an independent security audit before mainnet, including
fips204andfips205, as an open question. No audit result is claimed here.
The SignatureScheme trait
pub trait SignatureScheme {
const ID: SchemeId;
const SEED_LEN: usize;
const PUBLIC_KEY_LEN: usize;
const SIGNATURE_LEN: usize;
type PrivateKey: Clone;
fn keypair_from_seed(seed: &[u8]) -> Result<(Vec<u8>, Self::PrivateKey), Error>;
fn sign(sk: &Self::PrivateKey, msg: &[u8], ctx: &[u8]) -> Result<Vec<u8>, Error>;
fn verify(pk: &[u8], msg: &[u8], sig: &[u8], ctx: &[u8]) -> bool;
}
The trait works on raw bytes. Two unit structs implement it, MlDsa65 and SlhDsaSha2_192s. Everything above this layer goes through PublicKey, Signature and SecretKey, which carry the scheme and dispatch on it.
| Method | Behaviour |
|---|---|
keypair_from_seed | Deterministic. Fails with Error::InvalidLength if the seed length is wrong. |
sign | Hedged. Takes the context string as an argument and fails with Error::Signing if the crate does. |
verify | Never panics and never returns an error. A key or signature of the wrong length returns false, as does a key the crate refuses to decode. |
Scheme identifiers and sizes
#[repr(u8)]
#[non_exhaustive]
pub enum SchemeId {
MlDsa65 = 0x01,
SlhDsaSha2_192s = 0x02,
}
SchemeId | Byte | Name in JSON, CLI and key files | Seed | Public key | Signature |
|---|---|---|---|---|---|
MlDsa65 | 0x01 | ml-dsa-65 | 32 | 1952 | 3309 |
SlhDsaSha2_192s | 0x02 | slh-dsa-sha2-192s | 72 | 48 | 16224 |
Sizes are in bytes. The test scheme_sizes_match_implementations pins them, and scheme_ids_and_names_are_stable pins the bytes and names. Any other byte fails with Error::UnknownScheme, any other name with Error::UnknownSchemeName.
Seeds and key generation
A SecretKey holds three things: the seed in a Zeroizing<Vec<u8>>, the expanded private key, and the public key. Its Debug output is redacted. SecretKey::generate draws the seed from OsRng, which on wasm32-unknown-unknown is backed by getrandom with the js feature.
| Scheme | Seed structure | Passed to |
|---|---|---|
| ML-DSA-65 | 32 bytes, the FIPS 204 seed | ml_dsa_65::KG::keygen_from_seed |
| SLH-DSA-SHA2-192s | Three parts of N = 24 bytes, in the order SK.seed, SK.prf, PK.seed | slh_dsa_sha2_192s::KG::keygen_from_seed |
The seed is the only secret that gets stored. The key file encrypts it, and the expanded key is rebuilt from it on every load.
Signing
Signing is hedged in both schemes. fips204 draws fresh randomness in try_sign, and fips205 is called as try_sign(msg, ctx, true). Two signatures of the same message therefore differ, which the test signing_is_hedged checks. This is why a transaction id does not cover signatures, as Transactions and fees explains.
Both crates are used through the external, pure interface of FIPS 204 and FIPS 205. The message that reaches the internal algorithm is:
M' = 0x00 || len(ctx) as one byte || ctx || M
For ML-DSA the crate hashes tr and M' into mu. The pre-hash variants, HashML-DSA and HashSLH-DSA, are not called anywhere.
Encoding of keys and signatures
pub struct PublicKey { scheme: SchemeId, bytes: Box<[u8]> }
pub struct Signature { scheme: SchemeId, bytes: Box<[u8]> }
Both types share one encoding: the scheme byte, then the raw bytes. A consumer never needs to know the scheme in advance.
| Form | Layout | ML-DSA-65 key | ML-DSA-65 signature | SLH-DSA key | SLH-DSA signature |
|---|---|---|---|---|---|
| Raw | The scheme's own bytes | 1952 | 3309 | 48 | 16224 |
| Tagged | Scheme byte, then raw | 1953 | 3310 | 49 | 16225 |
| Borsh | u32 length, little-endian, then tagged | 1957 | 3314 | 53 | 16229 |
| JSON | Lowercase hex of the tagged form, as a string |
The borsh form is a Vec<u8> holding the tagged bytes. For the ML-DSA-65 key from the seed 0x01 repeated 32 times, an encoding starts:
a1070000 length 1953, little-endian
01 SchemeId::MlDsa65
c4e999a2 first bytes of the raw public key
Decoding is strict. from_tagged_bytes reads the scheme byte, then from_raw requires the exact length for that scheme. An SLH-DSA key starts 31000000 02.
| Input | Error |
|---|---|
| Empty bytes | Error::Empty |
| Unknown scheme byte | Error::UnknownScheme(id) |
| Wrong length for the scheme | Error::InvalidLength { what, expected, actual } |
| Non-hex JSON, or any of the above in JSON | A serde error carrying the same message |
from_raw checks length only. Whether the bytes decode as a key is decided at verification.
Verification
PublicKey::verify(domain, msg, sig) runs these steps:
- If the signature's scheme is not the key's scheme, return
Error::SchemeMismatch { key, signature }. - Take the context string from
domain. - Call the scheme's
verifywith the raw key, message, raw signature and context. - If it returns
false, returnError::InvalidSignature.
Tests cover a flipped bit at five positions in a signature, a flipped bit in a public key, signatures filled with 0x00, 0xff and 0x5a, and keys and signatures of the wrong length.
Error type
| Variant | Meaning |
|---|---|
UnknownScheme(u8) | The scheme byte has no scheme |
UnknownSchemeName(String) | The scheme name has no scheme |
InvalidLength { what, expected, actual } | A seed, key or signature has the wrong length |
Empty | Tagged bytes were empty |
SchemeMismatch { key, signature } | Key and signature are from different schemes |
InvalidSignature | Verification failed |
Rng | The operating system's random generator failed |
Signing(&'static str) | The signing crate reported an error |
WrongPasswordOrCorrupted | Key file decryption failed. keystore feature only. |
Keystore(String) | The key file is malformed or unsupported. keystore feature only. |
Domains
Every signature is bound to a Domain, and the domain's context string is the FIPS context ctx. A signature made in one domain fails in every other, for both schemes. The test signature_is_not_valid_in_another_domain runs every pair.
Domain | Context string | Length | Signed with it |
|---|---|---|---|
Transaction | qubits/tx/v1 | 12 | Transactions, in AuthPolicy, SignatureCache and PartialTransaction |
Consensus | qubits/consensus/v1 | 19 | Proposals, votes and timeouts in qubits-consensus |
BridgeAttestation | qubits/bridge/v1 | 16 | Nothing yet. The bridge is not implemented. |
Message | qubits/message/v1 | 17 | qubits sign and qubits verify, and the verify host call contracts use |
Handshake | qubits/handshake/v1 | 19 | The validator handshake in p2p.rs, over the TLS exporter of the connection |
Because the Message domain is shared, a signature a contract accepts through verify is never a valid transaction, and no transaction signature is valid to a contract. Off-chain messages from the CLI are in the same domain as those contracts check.
Hashing
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, BorshSerialize, BorshDeserialize)]
pub struct Hash(pub [u8; 32]);
impl Hash {
pub const ZERO: Hash = Hash([0; 32]);
pub fn digest(context: &str, data: &[u8]) -> Hash {
let mut hasher = blake3::Hasher::new_derive_key(context);
hasher.update(data);
Hash(*hasher.finalize().as_bytes())
}
}
Hash is written as 64 hex characters in text and JSON. Each use has its own context string, so a digest of one kind can't be passed off as another. The design doc keeps 256-bit BLAKE3: second-preimage resistance for addresses is high, and collision resistance is at NIST category 2.
Contexts in hash::context
| Constant | Context string | Input | Used for |
|---|---|---|---|
ADDRESS | qubits.sh 2026-11 address | borsh AuthPolicy | Account address |
TX_ID | qubits.sh 2026-11 transaction id | borsh Transaction | Transaction id |
GENESIS | qubits.sh 2026-11 genesis | borsh Genesis | Genesis hash |
GOVERNANCE | qubits.sh 2026-11 validator governance | Empty | governance_address() |
TX_ROOT_LEAF | qubits.sh 2026-11 tx root leaf | borsh SignedTransaction | Leaf of tx_root |
TX_ROOT_NODE | qubits.sh 2026-11 tx root node | 64 bytes, left and right child | Inner node of tx_root |
SIGNATURE_CACHE | qubits.sh 2026-11 signature cache | See Signature cache | Cache entry |
CONTRACT_ADDRESS | qubits.sh 2026-11 contract address | Deployer, 32 bytes, then nonce as u64 little-endian | Contract address |
CONTRACT_CODE | qubits.sh 2026-11 contract code | Contract code | code_hash |
CONTRACT_HASH | qubits.sh 2026-11 contract hash | Bytes from a contract | The hash host call |
RECEIPT_ROOT_LEAF | qubits.sh 2026-11 receipt root leaf | borsh Receipt | Leaf of receipt_root |
RECEIPT_ROOT_NODE | qubits.sh 2026-11 receipt root node | 64 bytes, left and right child | Inner node of receipt_root |
Contexts defined elsewhere
| Context string | Input | Defined in |
|---|---|---|
qubits.sh 2026-11 state tree | Node data of the Jellyfish Merkle Tree | qubits-state/src/store.rs |
qubits.sh 2026-11 consensus block | borsh block Header | qubits-consensus/src/types.rs |
qubits.sh 2026-11 consensus payload | borsh block payload | qubits-consensus/src/types.rs |
qubits.sh 2026-11 qc cache | borsh QuorumCert | qubits-consensus/src/types.rs |
qubits.sh 2026-11 timeout cert | borsh TimeoutCert | qubits-consensus/src/types.rs |
qubits.sh 2026-11 shard leaf | Shard index as u16 little-endian, then shard bytes | qubits-consensus/src/shards.rs |
qubits.sh 2026-11 shard node | 64 bytes, left and right child | qubits-consensus/src/shards.rs |
qubits.sh 2026-11 shard root | borsh of tree root, size, round and proposer | qubits-consensus/src/shards.rs |
Merkle roots
tx_root and receipt_root use one algorithm with different contexts:
- An empty list gives
Hash::ZERO. - Hash each item with the leaf context.
- While more than one hash remains, take them in pairs. Hash each pair's 64 bytes with the node context. A last hash without a partner moves up unchanged.
The result is a binary tree in list order. State uses a Jellyfish Merkle Tree instead, which State and blocks describes.
Encrypted key file
The keystore feature adds EncryptedKey, a JSON file holding one seed encrypted under a password. The CLI and the browser wallet use the same type, and both default to KdfParams::DEFAULT, so a file made by one opens in the other.
This file was written by EncryptedKey::encrypt with default parameters. The public_key value is shortened here; it has 3904 hex characters for ML-DSA-65 and 96 for SLH-DSA-SHA2-192s.
{
"version": 1,
"scheme": "ml-dsa-65",
"public_key": "c4e999a2033fb0e1...",
"kdf": {
"name": "argon2id",
"m_cost": 65536,
"t_cost": 3,
"p_cost": 1,
"salt": "b75b1d8786036aaf3462a7777ea5eff8"
},
"cipher": {
"name": "xchacha20poly1305",
"nonce": "d55a9c708fbff82aae91c0b1eab1bc5ac30415d45fbfb6c6"
},
"ciphertext": "27e01590e69ced78efd0e9b5c6c4097928139a105e7a83fbf03fe0498a6aa9979f3a4d83e6f511fe47c252d23db787be"
}
| Field | Type | Meaning |
|---|---|---|
version | u32 | Always 1 |
scheme | string | A SchemeId name |
public_key | hex | Raw public key, without the scheme byte |
kdf.name | string | Always argon2id |
kdf.m_cost | u32 | Argon2 memory in KiB |
kdf.t_cost | u32 | Argon2 passes |
kdf.p_cost | u32 | Argon2 lanes |
kdf.salt | hex | 16 random bytes |
cipher.name | string | Always xchacha20poly1305 |
cipher.nonce | hex | 24 random bytes |
ciphertext | hex | The encrypted seed followed by the 16-byte tag: 48 bytes for ML-DSA-65, 88 for SLH-DSA-SHA2-192s |
The file and both nested sections use deny_unknown_fields, so an extra field is refused. public_key here is untagged, while qubits key show prints the tagged form.
Parameters
| Parameter | Value |
|---|---|
| Argon2 variant and version | Argon2id, 0x13 |
Default m_cost | 65536 KiB, which is 64 MiB |
Default t_cost | 3 |
Default p_cost | 1 |
| Output | 32 bytes |
Largest accepted m_cost | 1048576 KiB, which is 1 GiB |
Largest accepted t_cost | 64 |
Largest accepted p_cost | 16 |
| Salt | 16 bytes from OsRng, fresh per encryption |
| Nonce | 24 bytes from OsRng, fresh per encryption |
| AAD tag | qubits-keystore/v1 |
MIN_PASSWORD_LEN | 12 characters |
The upper bounds exist because m_cost, t_cost and p_cost come from the file. They are checked before any hashing, so a hostile file can't make a node or wallet allocate unbounded memory.
MIN_PASSWORD_LEN is enforced by the CLI and by qubits-wasm, both counting Unicode characters. EncryptedKey::encrypt itself refuses only an empty password. Encrypting with the defaults took between 67 and 70 ms on the machine in Timings.
Associated data
The authenticated data covers everything in the file that decryption depends on:
offset bytes field
0 18 "qubits-keystore/v1"
18 1 scheme byte
19 n raw public key
19+n 4 m_cost, u32 little-endian
23+n 4 t_cost, u32 little-endian
27+n 4 p_cost, u32 little-endian
31+n 16 salt
Editing public_key, m_cost, t_cost, p_cost or the salt makes the tag fail, so the edit is reported the same way as a wrong password. The tests swapping_the_public_key_is_detected and editing_authenticated_fields_is_detected check this.
Encryption
- Refuse an empty password.
- Draw the salt and the nonce from
OsRng. - Build the associated data.
- Derive a 32-byte key with Argon2id from the password, parameters and salt.
- Encrypt the seed with XChaCha20-Poly1305 under that key, nonce and associated data.
- Write the JSON.
Decryption
- Refuse any
versionother than 1, and any otherkdf.nameorcipher.name. - Parse the scheme and public key. Hex-decode the salt to exactly 16 bytes, the nonce to exactly 24, and the ciphertext.
- Check the KDF limits, then derive the key.
- Decrypt. Any tag failure is
Error::WrongPasswordOrCorrupted. - Rebuild the key from the seed. If its public key differs from the file's, fail with
Error::Keystore.
| Failure | Error |
|---|---|
| Unknown field, bad JSON | Error::Keystore |
| Unsupported version, KDF or cipher | Error::Keystore |
| KDF parameters above the limits | Error::Keystore, before hashing |
| Wrong scheme name | Error::UnknownSchemeName |
| Salt or nonce of the wrong length, or not hex | Error::Keystore |
| Public key of the wrong length for the scheme | Error::InvalidLength |
| Wrong password, or an authenticated field edited | Error::WrongPasswordOrCorrupted |
| Seed does not produce the file's public key | Error::Keystore |
Files on disk
qubits key new opens the path with create_new, sets mode 0600 on Unix, syncs the file and refuses to overwrite an existing one. With --password-file, the CLI and the node use the first line of that file.
Test vectors
tests/vectors/ holds external vectors, which pin the behaviour the pinned crates must keep.
| File | Source | Cases | Accept | Reject |
|---|---|---|---|---|
ml-dsa-65-keygen.txt | NIST ACVP ML-DSA-keyGen-FIPS204, first 3 cases | 3 | ||
slh-dsa-sha2-192s-keygen.txt | NIST ACVP SLH-DSA-keyGen-FIPS205, first 3 cases | 3 | ||
ml-dsa-65-verify-acvp.txt | NIST ACVP ML-DSA-sigVer-FIPS204 | 15 | 3 | 12 |
ml-dsa-65-verify-wycheproof.txt | Project Wycheproof, mldsa_65_verify_test.json | 210 | 79 | 131 |
slh-dsa-sha2-192s-verify-acvp.txt | NIST ACVP SLH-DSA-sigVer-FIPS205 | 14 | 2 | 12 |
The key generation files check that a seed maps to the public key the standard specifies. Each line is seed pk for ML-DSA-65 and skSeed skPrf pkSeed pk for SLH-DSA-SHA2-192s.
The verification files use the external interface in pure mode, one case per line, with - for an empty field:
valid(1/0) public-key signature message context
tests/verification.rs calls fips204 and fips205 directly with each case's own context and requires the crate's answer to equal the expected one. Rejected cases count as much as accepted ones: they pin which signatures are invalid, and that is what would fork the chain.
cargo test -p qubits-crypto --all-features
cargo check -p qubits-crypto --all-features --target wasm32-unknown-unknown
Timings
cargo bench -p qubits-crypto measures keygen from a seed, signing and verification of a 256-byte message in Domain::Transaction, with criterion and 10 samples. These are the middle estimates from one run on an Apple M5 Max with the pinned toolchain, Rust 1.97.1, while other work was running on the machine.
| Scheme | Keygen | Sign | Verify |
|---|---|---|---|
| ML-DSA-65 | 138.61 µs | 429.40 µs | 100.47 µs |
| SLH-DSA-SHA2-192s | 111.81 ms | 959.85 ms | 730.11 µs |
Signing with SLH-DSA takes about a second, which suits a recovery key used rarely and offline. Its signatures are also 4.9 times the size of ML-DSA-65 signatures, and fees follow size, which Transactions and fees shows in numbers.
Signature cache
SignatureCache, in qubits-types, remembers which transaction signatures have verified. A validator checks a transaction when it arrives and again in every block built on it, and ML-DSA makes that the most expensive step of a block. An entry states a fact about a key, a message and a signature, so it never goes stale. Whether that key may sign for the account is still decided at execution, against the policy in force.
An entry is the digest, under the SIGNATURE_CACHE context, of:
raw public key
message length, u64 little-endian
message
raw signature
The domain is not hashed in because the cache verifies in Domain::Transaction only. A signature made for another domain fails verification and is never stored.
| Constant | Value | Meaning |
|---|---|---|
GENERATION | 200,000 | Entries in the young set before it replaces the old one |
PARALLEL_FROM | 8 | Fewest missing signatures worth starting threads for |
The cache has two sets, young and old. A lookup checks both. An insert into a full young first moves it to old, dropping the previous old, so memory is bounded at two generations.
SignatureCache::verify runs these steps:
- Compute the entry.
- If either set holds it, return success.
- Verify in
Domain::Transaction. A failure returns the error and stores nothing. - Store the entry.
SignatureCache::prewarm takes a list of jobs, filters out those already known, and returns if fewer than PARALLEL_FROM remain or only one thread is available. Otherwise it splits them across available_parallelism threads and calls verify on each, ignoring the results. Failures are left for execution to report.
Executor::prewarm builds that list for a block. For each transaction it takes the owner policy, or the revealed one, or the recovery policy for Role::Recovery, as the state stands before the block, and pairs each signature with policy.keys[index]. This is only a head start. A transaction that changes a key makes a later one miss, and that one verifies in turn during execution.
The cache and prewarm change when work happens and never the result. Their constants are not part of the on-chain format.
Not implemented
| Item | Status in the design doc |
|---|---|
Bridge attestations in Domain::BridgeAttestation | Planned with the Solana bridge. Not implemented. |
| Periodic SLH-DSA checkpoint signatures from validators, against a break of ML-DSA | Open question. Not implemented. |
| STARK-based aggregation of signatures | Open question. Not implemented. |
Source
chain/crates/qubits-crypto/Cargo.tomlchain/crates/qubits-crypto/src/scheme.rschain/crates/qubits-crypto/src/keys.rschain/crates/qubits-crypto/src/codec.rschain/crates/qubits-crypto/src/error.rschain/crates/qubits-crypto/src/keystore.rschain/crates/qubits-crypto/benches/signatures.rschain/crates/qubits-crypto/tests/signatures.rschain/crates/qubits-crypto/tests/verification.rschain/crates/qubits-crypto/tests/keystore.rschain/crates/qubits-crypto/tests/vectors/chain/crates/qubits-types/src/hash.rschain/crates/qubits-types/src/block.rschain/crates/qubits-types/src/contract.rschain/crates/qubits-types/src/signatures.rschain/crates/qubits-execution/src/lib.rs