BIP 340: Schnorr Signatures for secp256k1
BIP 340 Schnorr signatures for secp256k1 — official test vectors plus a runnable Python verifier, and why Schnorr beats ECDSA in Taproot.
| Type | Bitcoin Improvement Proposal |
| Number | 340 |
| Status | Final |
| Authors | Pieter Wuille, Jonas Nick, Tim Ruffing |
| Original | https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki |
BIP-340: Schnorr Signatures for secp256k1
BIP-340 defines Schnorr signatures for Bitcoin, activated with Taproot in November 2021. Schnorr signatures are simpler and more efficient than ECDSA.
Why Schnorr?
Advantages Over ECDSA
| Feature | ECDSA | Schnorr |
|---|---|---|
| Signature size | 71-73 bytes | 64 bytes |
| Verification | Slower | Faster |
| Batch verification | No | Yes |
| Key aggregation | Complex (MuSig) | Native |
| Provable security | Assumed | Proven |
Key Benefits
- Smaller signatures: 64 bytes vs 71-73 bytes
- Batch verification: Verify multiple signatures faster than individually
- Native multisig: n-of-n looks like single sig
- Simpler math: Easier to analyze and implement
Signature Scheme
Key Generation
Same as ECDSA—secp256k1 curve:
Private key: d (256-bit scalar)
Public key: P = d × G
Public Key Encoding
BIP-340 uses x-only public keys (32 bytes instead of 33):
- Only x-coordinate stored
- y-coordinate implicitly even
- If y is odd, negate private key
def lift_x(x):
"""Recover point from x-coordinate"""
y_sq = (x**3 + 7) % p
y = modular_sqrt(y_sq, p)
if y % 2 != 0:
y = p - y
return (x, y)
Signing Algorithm
def schnorr_sign(message, private_key):
# Ensure private key yields even y
d = private_key
P = d * G
if P.y % 2 != 0:
d = n - d
# Generate nonce deterministically
t = xor(bytes(d), tagged_hash("BIP0340/aux", aux_rand))
k = int(tagged_hash("BIP0340/nonce", t || bytes(P.x) || message)) % n
if k == 0:
raise Error("Invalid nonce")
R = k * G
if R.y % 2 != 0:
k = n - k
# Challenge
e = int(tagged_hash("BIP0340/challenge", bytes(R.x) || bytes(P.x) || message)) % n
# Signature
s = (k + e * d) % n
return bytes(R.x) || bytes(s)
Verification Algorithm
def schnorr_verify(message, public_key_x, signature):
P = lift_x(public_key_x)
r = int(signature[:32])
s = int(signature[32:])
if r >= p or s >= n:
return False
e = int(tagged_hash("BIP0340/challenge", bytes(r) || bytes(P.x) || message)) % n
R = s * G - e * P
if R.y % 2 != 0:
return False
if R.x != r:
return False
return True
Tagged Hashes
BIP-340 uses domain-separated hashes:
def tagged_hash(tag, message):
tag_hash = sha256(tag.encode())
return sha256(tag_hash + tag_hash + message)
Tags used:
BIP0340/aux- Auxiliary randomnessBIP0340/nonce- Nonce generationBIP0340/challenge- Signature challenge
Batch Verification
Verify n signatures faster than n individual verifications:
def batch_verify(messages, public_keys, signatures):
# Parse all signatures
points = []
for i, (m, pk, sig) in enumerate(zip(messages, public_keys, signatures)):
P = lift_x(pk)
r, s = parse_signature(sig)
e = challenge(r, P.x, m)
R = lift_x(r)
# Random coefficient
a = random_scalar() if i > 0 else 1
points.append((a, R))
points.append((a * e, P))
points.append((a * s, -G))
# Single multi-scalar multiplication
result = multi_scalar_mult(points)
return result == INFINITY
Speedup: ~2x for 100 signatures.
Key Aggregation (MuSig)
Multiple parties create a joint public key:
P_agg = P_1 + P_2 + ... + P_n
Single signature valid for aggregate key:
- Looks like single-sig on chain
- All n parties must participate
- Enables efficient n-of-n multisig
MuSig2 Protocol
Improved 2-round protocol:
- Round 1: Exchange nonce commitments
- Round 2: Exchange nonces, create partial signatures
# Simplified MuSig2
def musig2_sign(private_keys, message):
# Each party generates two nonces
R1, R2 = generate_nonces()
# Aggregate nonces
R_agg = sum(R1_i) + b * sum(R2_i)
# Each party creates partial signature
s_i = k_i + e * a_i * d_i
# Aggregate
s = sum(s_i)
return (R_agg.x, s)
Implementation
JavaScript (noble-secp256k1)
import * as secp from '@noble/secp256k1';
// Sign
const signature = await secp.schnorr.sign(messageHash, privateKey);
// Verify
const isValid = await secp.schnorr.verify(signature, messageHash, publicKey);
Python (reference implementation)
from bip340 import schnorr_sign, schnorr_verify
# Sign
sig = schnorr_sign(msg, seckey, aux_rand)
# Verify
valid = schnorr_verify(msg, pubkey, sig)
Test Vectors
These are the official BIP-340 test vectors. Every conforming implementation must reproduce them. A signature is 64 bytes: R.x (bytes 0–31) followed by s (bytes 32–63).
Vector 0 — valid (secret key …0003, all-zero message):
Public Key: F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9
Message: 0000000000000000000000000000000000000000000000000000000000000000
Signature: E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA821525F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0
Result: valid
Vector 3 — valid (all-FF message; fails if the message is reduced modulo p or n):
Public Key: 25D1DFF95105F5253C4022F628A996AD3A0D95FBF21D468A1B33F8C160D8F517
Message: FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
Signature: 7EB0509757E246F19449885651611CB965ECC1A187DD51B64FDA1EDC9637D5EC97582B9CB13DB3933705B32BA982AF5AF25FD78881EBB32771FC5922EFC66EA3
Result: valid
Vector 5 — invalid (public key not on the curve — a verifier must reject it):
Public Key: EEFDEA4CDB677750A420FEE807EACF21EB9898AE79B9768766E4FAA04A2D4A34
Message: 243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89
Signature: 6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E17776969E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B
Result: rejected
Verify it yourself
Pure-Python BIP-340 verification — standard library only, no dependencies. It checks all three vectors above against their expected results:
"""BIP-340 Schnorr verification, checked against the official test vectors."""
import hashlib
p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8)
def tagged_hash(tag, msg):
t = hashlib.sha256(tag.encode()).digest()
return hashlib.sha256(t + t + msg).digest()
def point_add(P1, P2):
if P1 is None: return P2
if P2 is None: return P1
if P1[0] == P2[0] and P1[1] != P2[1]: return None
if P1 == P2:
lam = 3 * P1[0] * P1[0] * pow(2 * P1[1], p - 2, p) % p
else:
lam = (P2[1] - P1[1]) * pow(P2[0] - P1[0], p - 2, p) % p
x3 = (lam * lam - P1[0] - P2[0]) % p
return (x3, (lam * (P1[0] - x3) - P1[1]) % p)
def point_mul(P, k):
R = None
while k:
if k & 1: R = point_add(R, P)
P = point_add(P, P); k >>= 1
return R
def lift_x(x):
if x >= p: return None
y2 = (pow(x, 3, p) + 7) % p
y = pow(y2, (p + 1) // 4, p)
if pow(y, 2, p) != y2: return None
return (x, y if y % 2 == 0 else p - y)
def schnorr_verify(msg: bytes, pubkey: bytes, sig: bytes) -> bool:
if len(pubkey) != 32 or len(sig) != 64:
return False
P = lift_x(int.from_bytes(pubkey, "big"))
if P is None:
return False
r = int.from_bytes(sig[0:32], "big")
s = int.from_bytes(sig[32:64], "big")
if r >= p or s >= n:
return False
e = int.from_bytes(tagged_hash("BIP0340/challenge", sig[0:32] + pubkey + msg), "big") % n
R = point_add(point_mul(G, s), point_mul(P, n - e))
if R is None or R[1] % 2 != 0 or R[0] != r:
return False
return True
# (public key, message, signature, expected result) — official BIP-340 vectors 0, 3, 5
vectors = [
("F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9",
"0000000000000000000000000000000000000000000000000000000000000000",
"E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA821525F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0", True),
("25D1DFF95105F5253C4022F628A996AD3A0D95FBF21D468A1B33F8C160D8F517",
"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
"7EB0509757E246F19449885651611CB965ECC1A187DD51B64FDA1EDC9637D5EC97582B9CB13DB3933705B32BA982AF5AF25FD78881EBB32771FC5922EFC66EA3", True),
("EEFDEA4CDB677750A420FEE807EACF21EB9898AE79B9768766E4FAA04A2D4A34",
"243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89",
"6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E17776969E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", False),
]
for pubkey_hex, msg_hex, sig_hex, expected in vectors:
result = schnorr_verify(bytes.fromhex(msg_hex), bytes.fromhex(pubkey_hex), bytes.fromhex(sig_hex))
print(f"{'PASS' if result == expected else 'FAIL'} expected={expected} got={result}")
Expected output:
PASS expected=True got=True
PASS expected=True got=True
PASS expected=False got=False
Security Properties
Proven Security
Schnorr security reduces to the Discrete Logarithm Problem (DLP) in the Random Oracle Model.
Resistance to Attacks
| Attack | Mitigation |
|---|---|
| Related-key | Use random auxiliary data |
| Nonce reuse | Deterministic nonce |
| Key cancellation | MuSig key aggregation |
For Agents
When to Use Schnorr
- All Taproot (P2TR) transactions
- Efficient multisig via MuSig2
- Batch verification scenarios
Libraries
| Language | Library |
|---|---|
| JavaScript | @noble/secp256k1 |
| Python | python-secp256k1, bip340 ref |
| Rust | secp256k1, k256 |
Signature Format
64 bytes total:
- bytes 0-31: R.x (x-coordinate of nonce point)
- bytes 32-63: s (scalar)
Machine-Readable Summary
{
"bip": 340,
"title": "Schnorr Signatures for secp256k1",
"status": "final",
"signature_size": 64,
"public_key_size": 32,
"features": ["batch-verification", "key-aggregation", "provable-security"],
"hash_tags": ["BIP0340/aux", "BIP0340/nonce", "BIP0340/challenge"],
"libraries": {
"javascript": "@noble/secp256k1",
"rust": "secp256k1",
"python": "bip340"
}
}