All pages

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

PurposePrimitiveCrateRequirementLocked version
Default and validator signaturesML-DSA-65, FIPS 204fips204=0.5.00.5.0
Recovery and cold-key signaturesSLH-DSA-SHA2-192s, FIPS 205fips205=0.5.00.5.0
Every hashBLAKE3 in derive-key mode, 256 bitblake31.81.8.7
Address textbech32mbech320.110.11.1
On-chain encodingborshborsh11.8.1
Key file key derivationArgon2id, version 0x13argon20.50.5.3
Key file encryptionXChaCha20-Poly1305chacha20poly13050.100.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 fips204 and fips205, 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.

MethodBehaviour
keypair_from_seedDeterministic. Fails with Error::InvalidLength if the seed length is wrong.
signHedged. Takes the context string as an argument and fails with Error::Signing if the crate does.
verifyNever 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,
}
SchemeIdByteName in JSON, CLI and key filesSeedPublic keySignature
MlDsa650x01ml-dsa-653219523309
SlhDsaSha2_192s0x02slh-dsa-sha2-192s724816224

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.

SchemeSeed structurePassed to
ML-DSA-6532 bytes, the FIPS 204 seedml_dsa_65::KG::keygen_from_seed
SLH-DSA-SHA2-192sThree parts of N = 24 bytes, in the order SK.seed, SK.prf, PK.seedslh_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.

FormLayoutML-DSA-65 keyML-DSA-65 signatureSLH-DSA keySLH-DSA signature
RawThe scheme's own bytes195233094816224
TaggedScheme byte, then raw195333104916225
Borshu32 length, little-endian, then tagged195733145316229
JSONLowercase 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.

InputError
Empty bytesError::Empty
Unknown scheme byteError::UnknownScheme(id)
Wrong length for the schemeError::InvalidLength { what, expected, actual }
Non-hex JSON, or any of the above in JSONA 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:

  1. If the signature's scheme is not the key's scheme, return Error::SchemeMismatch { key, signature }.
  2. Take the context string from domain.
  3. Call the scheme's verify with the raw key, message, raw signature and context.
  4. If it returns false, return Error::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

VariantMeaning
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
EmptyTagged bytes were empty
SchemeMismatch { key, signature }Key and signature are from different schemes
InvalidSignatureVerification failed
RngThe operating system's random generator failed
Signing(&'static str)The signing crate reported an error
WrongPasswordOrCorruptedKey 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.

DomainContext stringLengthSigned with it
Transactionqubits/tx/v112Transactions, in AuthPolicy, SignatureCache and PartialTransaction
Consensusqubits/consensus/v119Proposals, votes and timeouts in qubits-consensus
BridgeAttestationqubits/bridge/v116Nothing yet. The bridge is not implemented.
Messagequbits/message/v117qubits sign and qubits verify, and the verify host call contracts use
Handshakequbits/handshake/v119The 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

ConstantContext stringInputUsed for
ADDRESSqubits.sh 2026-11 addressborsh AuthPolicyAccount address
TX_IDqubits.sh 2026-11 transaction idborsh TransactionTransaction id
GENESISqubits.sh 2026-11 genesisborsh GenesisGenesis hash
GOVERNANCEqubits.sh 2026-11 validator governanceEmptygovernance_address()
TX_ROOT_LEAFqubits.sh 2026-11 tx root leafborsh SignedTransactionLeaf of tx_root
TX_ROOT_NODEqubits.sh 2026-11 tx root node64 bytes, left and right childInner node of tx_root
SIGNATURE_CACHEqubits.sh 2026-11 signature cacheSee Signature cacheCache entry
CONTRACT_ADDRESSqubits.sh 2026-11 contract addressDeployer, 32 bytes, then nonce as u64 little-endianContract address
CONTRACT_CODEqubits.sh 2026-11 contract codeContract codecode_hash
CONTRACT_HASHqubits.sh 2026-11 contract hashBytes from a contractThe hash host call
RECEIPT_ROOT_LEAFqubits.sh 2026-11 receipt root leafborsh ReceiptLeaf of receipt_root
RECEIPT_ROOT_NODEqubits.sh 2026-11 receipt root node64 bytes, left and right childInner node of receipt_root

Contexts defined elsewhere

Context stringInputDefined in
qubits.sh 2026-11 state treeNode data of the Jellyfish Merkle Treequbits-state/src/store.rs
qubits.sh 2026-11 consensus blockborsh block Headerqubits-consensus/src/types.rs
qubits.sh 2026-11 consensus payloadborsh block payloadqubits-consensus/src/types.rs
qubits.sh 2026-11 qc cacheborsh QuorumCertqubits-consensus/src/types.rs
qubits.sh 2026-11 timeout certborsh TimeoutCertqubits-consensus/src/types.rs
qubits.sh 2026-11 shard leafShard index as u16 little-endian, then shard bytesqubits-consensus/src/shards.rs
qubits.sh 2026-11 shard node64 bytes, left and right childqubits-consensus/src/shards.rs
qubits.sh 2026-11 shard rootborsh of tree root, size, round and proposerqubits-consensus/src/shards.rs

Merkle roots

tx_root and receipt_root use one algorithm with different contexts:

  1. An empty list gives Hash::ZERO.
  2. Hash each item with the leaf context.
  3. 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"
}
FieldTypeMeaning
versionu32Always 1
schemestringA SchemeId name
public_keyhexRaw public key, without the scheme byte
kdf.namestringAlways argon2id
kdf.m_costu32Argon2 memory in KiB
kdf.t_costu32Argon2 passes
kdf.p_costu32Argon2 lanes
kdf.salthex16 random bytes
cipher.namestringAlways xchacha20poly1305
cipher.noncehex24 random bytes
ciphertexthexThe 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

ParameterValue
Argon2 variant and versionArgon2id, 0x13
Default m_cost65536 KiB, which is 64 MiB
Default t_cost3
Default p_cost1
Output32 bytes
Largest accepted m_cost1048576 KiB, which is 1 GiB
Largest accepted t_cost64
Largest accepted p_cost16
Salt16 bytes from OsRng, fresh per encryption
Nonce24 bytes from OsRng, fresh per encryption
AAD tagqubits-keystore/v1
MIN_PASSWORD_LEN12 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

  1. Refuse an empty password.
  2. Draw the salt and the nonce from OsRng.
  3. Build the associated data.
  4. Derive a 32-byte key with Argon2id from the password, parameters and salt.
  5. Encrypt the seed with XChaCha20-Poly1305 under that key, nonce and associated data.
  6. Write the JSON.

Decryption

  1. Refuse any version other than 1, and any other kdf.name or cipher.name.
  2. Parse the scheme and public key. Hex-decode the salt to exactly 16 bytes, the nonce to exactly 24, and the ciphertext.
  3. Check the KDF limits, then derive the key.
  4. Decrypt. Any tag failure is Error::WrongPasswordOrCorrupted.
  5. Rebuild the key from the seed. If its public key differs from the file's, fail with Error::Keystore.
FailureError
Unknown field, bad JSONError::Keystore
Unsupported version, KDF or cipherError::Keystore
KDF parameters above the limitsError::Keystore, before hashing
Wrong scheme nameError::UnknownSchemeName
Salt or nonce of the wrong length, or not hexError::Keystore
Public key of the wrong length for the schemeError::InvalidLength
Wrong password, or an authenticated field editedError::WrongPasswordOrCorrupted
Seed does not produce the file's public keyError::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.

FileSourceCasesAcceptReject
ml-dsa-65-keygen.txtNIST ACVP ML-DSA-keyGen-FIPS204, first 3 cases3
slh-dsa-sha2-192s-keygen.txtNIST ACVP SLH-DSA-keyGen-FIPS205, first 3 cases3
ml-dsa-65-verify-acvp.txtNIST ACVP ML-DSA-sigVer-FIPS20415312
ml-dsa-65-verify-wycheproof.txtProject Wycheproof, mldsa_65_verify_test.json21079131
slh-dsa-sha2-192s-verify-acvp.txtNIST ACVP SLH-DSA-sigVer-FIPS20514212

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.

SchemeKeygenSignVerify
ML-DSA-65138.61 µs429.40 µs100.47 µs
SLH-DSA-SHA2-192s111.81 ms959.85 ms730.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.

ConstantValueMeaning
GENERATION200,000Entries in the young set before it replaces the old one
PARALLEL_FROM8Fewest 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:

  1. Compute the entry.
  2. If either set holds it, return success.
  3. Verify in Domain::Transaction. A failure returns the error and stores nothing.
  4. 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

ItemStatus in the design doc
Bridge attestations in Domain::BridgeAttestationPlanned with the Solana bridge. Not implemented.
Periodic SLH-DSA checkpoint signatures from validators, against a break of ML-DSAOpen question. Not implemented.
STARK-based aggregation of signaturesOpen question. Not implemented.

Source

  • chain/crates/qubits-crypto/Cargo.toml
  • chain/crates/qubits-crypto/src/scheme.rs
  • chain/crates/qubits-crypto/src/keys.rs
  • chain/crates/qubits-crypto/src/codec.rs
  • chain/crates/qubits-crypto/src/error.rs
  • chain/crates/qubits-crypto/src/keystore.rs
  • chain/crates/qubits-crypto/benches/signatures.rs
  • chain/crates/qubits-crypto/tests/signatures.rs
  • chain/crates/qubits-crypto/tests/verification.rs
  • chain/crates/qubits-crypto/tests/keystore.rs
  • chain/crates/qubits-crypto/tests/vectors/
  • chain/crates/qubits-types/src/hash.rs
  • chain/crates/qubits-types/src/block.rs
  • chain/crates/qubits-types/src/contract.rs
  • chain/crates/qubits-types/src/signatures.rs
  • chain/crates/qubits-execution/src/lib.rs