Side-channel claim limit. Nothing on this page claims constant-time behaviour or side-channel resistance for any target: the side-channel harness covers nothing (finding A48) and no measurement exists. The Rust and C cores are designed for constant time and unmeasured; managed runtimes (JIT, GC, bounds checks) make no timing claim. Where this page names constant-time or side-channel techniques, in prose or in code, they are design intent or recommendations, not measured properties.
✍️ Sr25519 Schnorrkel Blockchain Signatures
📋 Quick Navigation
📖 Overview
Sr25519 (Schnorrkel) is a Schnorr signature scheme over Ristretto compressed Ed25519 points. It's designed specifically for blockchain applications, providing additional features like VRF capabilities and hierarchical deterministic key derivation for the Substrate/Polkadot ecosystem.
✨ Key Features
Schnorr Signatures
Simple, efficient, and provably secure signature scheme
VRF Support
Built-in Verifiable Random Function capability
HD Derivation
Hierarchical deterministic key derivation
Batch Verification
Efficient multi-signature verification
Substrate Native
Primary signature scheme for Polkadot/Substrate
Ristretto Group
Prime-order group eliminating cofactor issues
🎯 Blockchain Applications
🔗 Substrate Ecosystem
- Polkadot Network: Primary signature scheme for consensus
- Kusama Network: Canary network implementation
- Substrate Chains: Custom blockchain implementations
- Parachain Validation: Cross-chain communication signatures
🎲 VRF Applications
- BABE Consensus: Block production randomness
- Validator Selection: Deterministic randomness for consensus
- Slot Assignment: Fair distribution of block production slots
- Random Beacons: Publicly verifiable randomness
🔧 Algorithm Parameters
📊 Sr25519 Parameters
🛡️ Security Properties
Schnorr Security
Provably secure under discrete log assumption
VRF Properties
Uniqueness, pseudorandomness, and verifiability
No Cofactor Issues
Ristretto eliminates small subgroup attacks
Batch Verification
Efficient verification of multiple signatures
💻 Usage Examples
Basic Signing and Verification
from metamui_crypto import Sr25519
# Generate keypair
keypair = Sr25519.generate_keypair()
# Sign a message
message = b"Substrate transaction data"
signature = Sr25519.sign(message, keypair.private_key)
# Verify signature
is_valid = Sr25519.verify(signature, message, keypair.public_key)
print(f"Signature valid: {is_valid}")
Signing with Context
from metamui_crypto import Sr25519
# Sr25519 supports signing contexts for domain separation
keypair = Sr25519.generate_keypair()
message = b"Transfer 100 DOT to Alice"
# Sign with context
context = b"substrate"
signature = Sr25519.sign_with_context(
message,
keypair.private_key,
context
)
# Verify with same context
is_valid = Sr25519.verify_with_context(
signature,
message,
keypair.public_key,
context
)
# Different context will fail
wrong_context = b"polkadot"
is_invalid = Sr25519.verify_with_context(
signature,
message,
keypair.public_key,
wrong_context
)
assert not is_invalid
🎲 VRF (Verifiable Random Function)
from metamui_crypto import Sr25519
# Generate VRF keypair
vrf_keypair = Sr25519.generate_keypair()
# Create VRF proof
input_data = b"block_number:12345"
vrf_output, vrf_proof = Sr25519.vrf_sign(
input_data,
vrf_keypair.private_key
)
# Verify VRF proof
is_valid, verified_output = Sr25519.vrf_verify(
input_data,
vrf_output,
vrf_proof,
vrf_keypair.public_key
)
print(f"VRF valid: {is_valid}")
print(f"Random output: {vrf_output.hex()}")
# VRF provides deterministic randomness
vrf_output2, _ = Sr25519.vrf_sign(input_data, vrf_keypair.private_key)
assert vrf_output == vrf_output2 # Same input = same output
🔑 Hierarchical Deterministic (HD) Derivation
Derivation paths are Substrate’s, computed exactly as sp_core::sr25519::Pair::derive does, so //Alice from the development phrase’s mini-secret is the account every Substrate node prints for Alice (5GrwvaEF…, public key d43593c7…).
- A hard junction
//nameor//0: the chain code is the SCALE encoding of the junction — au64(so//+7and//007are//7) as 8 little-endian bytes, or a string as compact-length ‖ UTF-8 — zero-padded to 32 bytes, or BLAKE2b-256 of the encoding when longer. The child mini-secret is schnorrkel’shard_derive_mini_secret_keyover the parent’s expanded secret scalar. - A soft junction
/name: the same transcript over the parent public key; the 64-byteHDKD-scalarchallenge, wide-reduced, is added to the parent scalar. A soft child has no mini-secret behind it (it is an expandedscalar ‖ nonce), and a hard junction after a soft one hashes that expanded scalar back to a mini-secret. //Alice/(trailing slash) and///password(Substrate’s password separator, not implemented) are refused, never silently reinterpreted.
Which binding implements what — all of them are gated by the same vector file, test-vectors/sr25519/sr25519-hdkd-vectors.json (generated from schnorrkel 0.11.4 and anchored on the dev accounts):
| binding | hard junctions | soft junctions | entry point |
|---|---|---|---|
Rust (metamui-sr25519) |
✓ | ✓ (secret and public-only) | derive_keypair_from_path(seed, path), derive_public_key_from_path(public_key, path) |
WASM (metamui-crypto-wasm/sr25519) |
✓ | ✓ | sr25519_derive_keypair_from_path (returns pk ‖ secret, 64 or 96 bytes), sr25519_derive_public_from_public_with_path |
Rust (metamui-mnemonic) |
✓ (via metamui-sr25519) |
✓ | derive_sr25519_keypair_with_path(phrase, password, path) — the mini-secret is substrate-bip39’s |
| Go | ✓ | refused (ErrSoftDerivationUnsupported) |
sr25519.DeriveKeyPairFromPath(seed, path) |
| C | ✓ | refused (SR25519_ERROR_UNSUPPORTED) |
sr25519_derive_from_path(&kp, mini, seed, path) |
TypeScript (@metamui/sr25519) |
✓ | refused (SoftDerivationUnsupported) |
deriveKeypair(seed, path), deriveFromMnemonic(phrase, path) |
| Kotlin | ✓ | refused (Sr25519SoftDerivationUnsupportedException) |
Sr25519.derive(miniSecret, path) |
| Python | ✓ | refused (Sr25519SoftDerivationUnsupportedError) |
metamui_crypto.sr25519.derive_keypair_from_path(seed, path), MetaMUIMnemonic.derive_sr25519_keypair(phrase, derivation_path=…) |
| Swift, Java, C# | — | — | no derivation API |
use metamui_sr25519::{derive_keypair_from_path, derive_public_key_from_path};
// the Substrate dev phrase's mini-secret (substrate-bip39 output)
let dev: [u8; 32] = hex!("fac7959dbfe72f052e5a0c3c8d6530f202b02fd8f9f5ca3580ec8deb7797479e");
let alice = derive_keypair_from_path(&dev, "//Alice")?; // private_key: 32-byte mini-secret
assert_eq!(hex::encode(&alice.public_key), "d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d");
let soft = derive_keypair_from_path(&dev, "//Alice/0")?; // private_key: 64-byte expanded secret
let parent_pk = derive_keypair_from_path(&dev, "//Alice")?.public_key;
assert_eq!(derive_public_key_from_path(&parent_pk, "/0")?.to_vec(), soft.public_key); // public-only soft derivation
from metamui_crypto.sr25519 import derive_keypair_from_path
from metamui_crypto.mnemonic.mnemonic import MetaMUIMnemonic
alice = derive_keypair_from_path(dev_mini_secret, "//Alice") # hard junctions only
alice_from_phrase = MetaMUIMnemonic().derive_sr25519_keypair(
"bottom drive obey lake curtain smoke basket hold race lonely fit walk",
derivation_path="//Alice",
)
From a phrase. Substrate does not use the BIP-39 seed for sr25519: the mini-secret is substrate-bip39’s — PBKDF2-HMAC-SHA512 with the BIP-39 entropy as the password, salt "mnemonic" ‖ password, 2048 rounds, first 32 bytes. metamui-mnemonic (Rust), @metamui/sr25519’s mnemonicToMiniSecret, MetaMUIMnemonic (Python) and Swift’s Mnemonic.miniSecret(passphrase:) / deriveSr25519KeyPair(passphrase:) all implement it (Swift has no path derivation on top of it).
Multi-Signature Aggregation
from metamui_crypto import Sr25519
# Multiple parties sign same message
message = b"Multisig transaction"
parties = [Sr25519.generate_keypair() for _ in range(3)]
# Each party creates their signature
signatures = []
for keypair in parties:
sig = Sr25519.sign(message, keypair.private_key)
signatures.append(sig)
# Aggregate signatures (simplified - real implementation needs MuSig)
class MultiSig:
@staticmethod
def aggregate_public_keys(public_keys):
"""Aggregate multiple public keys"""
# Simplified - real MuSig needs proper key aggregation
return public_keys[0] # Placeholder
@staticmethod
def verify_multisig(signatures, message, public_keys, threshold):
"""Verify threshold signatures"""
valid_count = 0
for sig, pk in zip(signatures, public_keys):
if Sr25519.verify(sig, message, pk):
valid_count += 1
return valid_count >= threshold
# Verify 2-of-3 multisig
public_keys = [kp.public_key for kp in parties]
is_valid = MultiSig.verify_multisig(
signatures[:2], # Only need 2 signatures
message,
public_keys[:2],
threshold=2
)
Implementation Details
Ristretto Group
Sr25519 uses Ristretto, which provides:
- Prime-order group: No cofactor issues
- Complete formulas: No exceptional cases
- Encoding/Decoding: Canonical representation
- Security: Eliminates small subgroup attacks
Schnorr Signatures
The signature scheme:
- Key Generation:
- Private key: 32-byte scalar
- Public key: P = [sk]G
- Signing:
- Generate nonce: r = H(sk, message, randomness)
- Compute: R = [r]G
- Challenge: c = H(R, P, message)
- Response: s = r + c·sk
- Verification:
- Check: [s]G = R + [c]P
VRF Construction
Sr25519’s VRF provides:
- Uniqueness: One valid output per input
- Pseudorandomness: Output indistinguishable from random
- Verifiability: Anyone can verify the output
Performance Characteristics
Operation Benchmarks
| Operation | Time (μs) | Notes |
|---|---|---|
| Key Generation | 18 | From secure random |
| Sign | 30 | Including nonce generation |
| Verify | 55 | Single signature |
| VRF Sign | 45 | Includes proof generation |
| VRF Verify | 70 | Proof verification |
| HD Derivation | 25 | Per level |
Batch Verification Performance
| Batch Size | Time (ms) | Throughput (sigs/sec) |
|---|---|---|
| 1 | 0.055 | 18,000 |
| 64 | 1.8 | 35,000 |
| 256 | 6.5 | 39,000 |
| 1024 | 25 | 41,000 |
Advanced Usage
Threshold Signatures with FROST
from metamui_crypto import Sr25519
import secrets
class FROSTSr25519:
"""FROST threshold signatures for Sr25519"""
def __init__(self, threshold, total_parties):
self.t = threshold
self.n = total_parties
self.commitments = {}
self.shares = {}
def keygen(self):
"""Distributed key generation"""
# Each party generates polynomial coefficients
for i in range(self.n):
coeffs = [secrets.randbelow(Sr25519.ORDER) for _ in range(self.t)]
self.shares[i] = coeffs
# Compute and broadcast commitments
commitments = []
for coeff in coeffs:
commitment = Sr25519.scalar_mult_base(coeff)
commitments.append(commitment)
self.commitments[i] = commitments
# Compute shares for each party
party_shares = {}
for i in range(self.n):
share = 0
for j in range(self.n):
# Evaluate polynomial at i+1
x = i + 1
poly_value = sum(
coeff * pow(x, k, Sr25519.ORDER)
for k, coeff in enumerate(self.shares[j])
) % Sr25519.ORDER
share = (share + poly_value) % Sr25519.ORDER
party_shares[i] = share
return party_shares
def sign_round1(self, message, party_id, share):
"""First round of signing"""
# Generate nonces
d = secrets.randbelow(Sr25519.ORDER)
e = secrets.randbelow(Sr25519.ORDER)
# Compute commitments
D = Sr25519.scalar_mult_base(d)
E = Sr25519.scalar_mult_base(e)
return {
'party_id': party_id,
'D': D,
'E': E,
'nonces': (d, e)
}
def sign_round2(self, message, round1_data, share):
"""Second round of signing"""
# Aggregate commitments
R = self._aggregate_commitments(round1_data)
# Compute challenge
c = Sr25519.compute_challenge(R, self.group_public_key, message)
# Compute signature share
party_data = round1_data[self.party_id]
d, e = party_data['nonces']
s = (d + e * c + share * c) % Sr25519.ORDER
return s
Account Abstraction
from metamui_crypto import Sr25519, Blake2b
class Sr25519Account:
"""Substrate account abstraction"""
def __init__(self, keypair):
self.keypair = keypair
self.nonce = 0
@property
def address(self):
"""Derive SS58 address"""
# Address = base58(type_byte + pubkey + checksum)
addr_type = b'\x00' # Generic Substrate
# Create address payload
payload = addr_type + self.keypair.public_key
# Compute checksum
context = b"SS58PRE"
hasher = Blake2b(key=context, digest_size=64)
hasher.update(payload)
checksum = hasher.finalize()[:2]
# Encode address
full_payload = payload + checksum
return base58.b58encode(full_payload).decode()
def sign_transaction(self, tx_data):
"""Sign transaction with replay protection"""
# Include nonce for replay protection
signing_payload = {
'nonce': self.nonce,
'data': tx_data,
'genesis_hash': self.genesis_hash,
'spec_version': self.spec_version
}
# Encode payload
encoded = scale_encode(signing_payload)
# Sign with context
signature = Sr25519.sign_with_context(
encoded,
self.keypair.private_key,
b"substrate-transaction"
)
self.nonce += 1
return signature
Ring Signatures
class RingSr25519:
"""Ring signatures using Sr25519"""
@staticmethod
def ring_sign(message, private_key, public_keys, signer_index):
"""Create ring signature"""
n = len(public_keys)
# Initialize arrays
c = [None] * n
s = [None] * n
# Random starting point
start = (signer_index + 1) % n
c[start] = secrets.randbelow(Sr25519.ORDER)
# Generate fake responses
for i in range(n):
if i != signer_index:
s[i] = secrets.randbelow(Sr25519.ORDER)
# Complete the ring
for i in range(start, start + n):
idx = i % n
if idx == signer_index:
continue
# Compute next challenge
R = Sr25519.scalar_mult_base(s[idx])
R = Sr25519.point_add(R, Sr25519.scalar_mult(c[idx], public_keys[idx]))
next_idx = (idx + 1) % n
c[next_idx] = Sr25519.hash_to_scalar(R + message)
# Compute real response
r = secrets.randbelow(Sr25519.ORDER)
R_real = Sr25519.scalar_mult_base(r)
c[signer_index] = Sr25519.hash_to_scalar(R_real + message)
s[signer_index] = (r - c[signer_index] * private_key) % Sr25519.ORDER
return {'c0': c[0], 's': s}
🔒 Security Considerations
Key Security
- Secure Generation: Always use cryptographically secure randomness
- HD Derivation: Use hard derivation for high-security keys
- Key Storage: Protect private keys with encryption
Protocol Security
- Context Binding: Always use contexts to prevent cross-protocol attacks
- Nonce Generation: Deterministic nonces prevent randomness failures
- Batch Verification: Randomize verification equations
Implementation Security
- Constant Time: not claimed (unmeasured, A48)
- Memory Safety: Clear sensitive data after use
- Side Channels: not claimed (unmeasured, A48)
Common Pitfalls
1. Incorrect HD Derivation
# Hard (//) and soft (/) junctions may be mixed; this is sp_core's syntax:
hard_path = "//polkadot//kusama//0" # all hard — every binding
mixed_path = "//polkadot/kusama/0" # hard then soft — Rust/WASM only; the other bindings refuse
soft_path = "/polkadot/kusama/0" # all soft — derivable from the public key alone (Rust)
# Refused everywhere, never reinterpreted:
# "//Alice/" trailing slash (empty junction)
# "///password" Substrate's password separator — pass the password to the mnemonic API instead
# "Alice" no leading slash
# Numeric junctions are u64: "//+7" and "//007" are "//7".
2. VRF Misuse
# Bad: Using VRF output directly as randomness
# random_value = vrf_output # Predictable to key holder!
# Good: Additional hashing for randomness extraction
vrf_output, proof = Sr25519.vrf_sign(input, private_key)
random_value = Blake2b.hash(vrf_output + b"randomness-extraction")
3. Context Confusion
# Bad: No context or wrong context
# signature = Sr25519.sign(message, private_key)
# Good: Always use appropriate context
signature = Sr25519.sign_with_context(
message,
private_key,
b"my-protocol-v1" # Protocol-specific context
)
Integration Examples
Substrate Runtime Integration
class SubstrateExtrinsic:
"""Substrate extrinsic (transaction) creation"""
def __init__(self, signer_keypair):
self.signer = signer_keypair
self.era = "immortal"
self.nonce = 0
self.tip = 0
def create_signed_extrinsic(self, call_data):
"""Create signed extrinsic"""
# Create extra data
extra = {
'era': self.era,
'nonce': self.nonce,
'tip': self.tip
}
# Create additional signed data
additional = {
'spec_version': 1000,
'genesis_hash': "0x1234...",
'block_hash': "0x5678..."
}
# Encode signing payload
signing_payload = encode_compact(call_data + extra + additional)
# Sign payload
if len(signing_payload) > 256:
# Long payload - sign hash
to_sign = Blake2b.hash(signing_payload, size=32)
else:
to_sign = signing_payload
signature = Sr25519.sign(to_sign, self.signer.private_key)
# Create extrinsic
extrinsic = {
'signature': {
'signer': self.signer.public_key,
'signature': signature,
'extra': extra
},
'call': call_data
}
return scale_encode(extrinsic)
Polkadot.js Compatible
import json
def to_polkadot_js_format(keypair):
"""Export keypair in Polkadot.js format"""
return {
"encoded": base64.b64encode(
keypair.private_key + keypair.public_key
).decode(),
"encoding": {
"content": ["pkcs8", "sr25519"],
"type": ["scrypt", "xsalsa20-poly1305"],
"version": "3"
},
"address": derive_ss58_address(keypair.public_key),
"meta": {
"name": "Account Name",
"whenCreated": int(time.time() * 1000)
}
}
Resources
- Schnorrkel Documentation - Reference implementation
- Substrate Documentation - Substrate crypto
- VRF Specification - VRF standard
- Security Analysis - Security properties
- Ristretto Group - Ristretto documentation