This document specifies a custom Schnorr-based signature scheme on the secp256k1 elliptic curve. The scheme is used by Chronicle Protocol's Scribe oracle contract.
-
H()
- Keccak256 hash function -
‖
- Concatenation operator, defined asabi.encodePacked()
-
G
- Generator of secp256k1 -
Q
- Order of secp256k1 -
x
- The signer's private key as typeuint256
-
P
- The signer's public key, i.e.[x]G
, as type(uint256, uint256)
-
Pₓ
- P's x coordinate as typeuint256
-
Pₚ
- Parity ofP
'sy
coordinate, i.e.0
if even,1
if odd, as typeuint8
-
m
- Message as typebytes32
. Note that the message SHOULD be a keccak256 digest -
k
- Nonce as typeuint256
-
Select a cryptographically secure random
k ∊ [1, Q)
-
Compute
R = [k]G
-
Derive
Rₑ
being the Ethereum address ofR
Let
Rₑ
be the commitment -
Construct
e = H(Pₓ ‖ Pₚ ‖ m ‖ Rₑ) mod Q
Let
e
be the challenge -
Compute
s = k + (e * x) mod Q
Let
s
be the signature
=> The public key P
signs via the signature s
and the commitment Rₑ
the
message m
- Input :
(P, m, s, Rₑ)
- Output:
True
if signature verification succeeds,false
otherwise
-
Compute challenge
e = H(Pₓ ‖ Pₚ ‖ m ‖ Rₑ) mod Q
-
Compute commitment:
[s]G - [e]P | s = k + (e * x)
= [k + (e * x)]G - [e]P | P = [x]G
= [k + (e * x)]G - [e * x]G | Distributive Law
= [k + (e * x) - (e * x)]G | (e * x) - (e * x) = 0
= [k]G | R = [k]G
= R | Let ()ₑ be the Ethereum address of a Point
→ Rₑ
- Verification succeeds iff
([s]G - [e]P)ₑ = Rₑ
In order to efficiently aggregate public keys onchain, the key aggregation mechanism for aggregated signatures is specified as the sum of the public keys:
Let the signers' public keys be:
signers = [pubKey₁, pubKey₂, ..., pubKeyₙ]
Let the aggregated public key be:
aggPubKey = sum(signers)
= pubKey₁ + pubKey₂ + ... + pubKeyₙ
= [privKey₁]G + [privKey₂]G + ... + [privKeyₙ]G
= [privKey₁ + privKey₂ + ... + privKeyₙ]G
Note that this aggregation scheme is vulnerable to rogue-key attacks1! In order to prevent such attacks a separate public key validation step, called a proof of possession, must be performed. This proof of possession can be implemented via an ECDSA signature, however, the message signed MUST be derived from the respective public key2.
Note further that this aggregation scheme is vulnerable to public keys with
linear relationships. A set of public keys A
leaking the sum of their private
keys would allow the creation of a second set of public keys B
with
aggPubKey(A) = aggPubKey(B)
. This would make signatures created by set A
indistinguishable from signatures created by set B
.
However, this specification assumes that participants do not share private key
material leading to negligible probability for such cases to happen.
Note that the signing scheme deviates slightly from the classical Schnorr signature scheme.
Instead of using the secp256k1 point R = [k]G
directly, this scheme uses the
Ethereum address of the point R
. This decreases the difficulty of
brute-forcing the signature from 256 bits
(trying random secp256k1 points)
to 160 bits
(trying random Ethereum addresses).
However, the difficulty of cracking a secp256k1 public key using the
baby-step giant-step algorithm is O(√Q)
, with Q
being the order of the group3.
Note that √Q ~ 3.4e38 < 128 bit
.
Therefore, this signing scheme does not weaken the overall security.
This implementation uses the ecrecover precompile to perform the necessary elliptic curve multiplication in secp256k1 during the verification process.
The ecrecover precompile can roughly be implemented in python via4:
def ecdsa_raw_recover(msghash, vrs):
v, r, s = vrs
y = # (get y coordinate for EC point with x=r, with same parity as v)
Gz = jacobian_multiply((Gx, Gy, 1), (Q - hash_to_int(msghash)) % Q)
XY = jacobian_multiply((r, y, 1), s)
Qr = jacobian_add(Gz, XY)
N = jacobian_multiply(Qr, inv(r, Q))
return from_jacobian(N)
Note that ecrecover also uses s
as variable. From this point forward, let
the Schnorr signature's s
be sig
.
A single ecrecover call can compute ([sig]G - [e]P)ₑ = ([k]G)ₑ = Rₑ
via the
following inputs:
msghash = -sig * Pₓ
v = Pₚ + 27
r = Pₓ
s = Q - (e * Pₓ)
Note that ecrecover returns the Ethereum address of R
and not R
itself.
The ecrecover call then digests to:
Gz = [Q - (-sig * Pₓ)]G | Double negation
= [Q + (sig * Pₓ)]G | Addition with Q can be removed in (mod Q)
= [sig * Pₓ]G | sig = k + (e * x)
= [(k + (e * x)) * Pₓ]G
XY = [Q - (e * Pₓ)]P | P = [x]G
= [(Q - (e * Pₓ)) * x]G
Qr = Gz + XY | Gz = [(k + (e * x)) * Pₓ]G
= [(k + (e * x)) * Pₓ]G + XY | XY = [(Q - (e * Pₓ)) * x]G
= [(k + (e * x)) * Pₓ]G + [(Q - (e * Pₓ)) * x]G
N = Qr * Pₓ⁻¹ | Qr = [(k + (e * x)) * Pₓ]G + [(Q - (e * Pₓ)) * x]G
= [(k + (e * x)) * Pₓ]G + [(Q - (e * Pₓ)) * x]G * Pₓ⁻¹ | Distributive law
= [(k + (e * x)) * Pₓ * Pₓ⁻¹]G + [(Q - (e * Pₓ)) * x * Pₓ⁻¹]G | Pₓ * Pₓ⁻¹ = 1
= [(k + (e * x))]G + [Q - e * x]G | sig = k + (e * x)
= [sig]G + [Q - e * x]G | Q - (e * x) = -(e * x) in (mod Q)
= [sig]G - [e * x]G | P = [x]G
= [sig]G - [e]P