'''
GG18 Threshold ECDSA Signing Protocol
| From: "Fast Multiparty Threshold ECDSA with Fast Trustless Setup"
| By: Rosario Gennaro, Steven Goldfeder
| Published: CCS 2018 / ePrint 2019/114
| URL: https://eprint.iacr.org/2019/114.pdf
* type: threshold signature
* setting: Elliptic Curve + Paillier
* assumption: DDH, DCR, ROM
This module implements the GG18 threshold ECDSA signing protocol.
Unlike DKLS23, GG18 uses Paillier-based MtA and requires 4 interactive
rounds for each signature (no presigning).
Protocol Overview (4 rounds):
1. Round 1: Generate k_i, gamma_i, broadcast commitment C_i = H(g^{gamma_i})
2. Round 2: Broadcast g^{gamma_i}, run MtA protocols
3. Round 3: Broadcast delta_i = k_i * gamma_i, compute R
4. Round 4: Compute and broadcast signature share s_i
:Authors: Charm Developers
:Date: 02/2026
'''
from charm.toolbox.ecgroup import ECGroup, ZR, G
from charm.toolbox.integergroup import RSAGroup
from charm.toolbox.PKSig import PKSig
from charm.toolbox.paillier_mta import PaillierMtA, PaillierKeyPair
from charm.schemes.threshold.gg18_dkg import GG18_DKG, GG18_KeyShare
from charm.core.engine.util import objectToBytes, bytesToObject
from typing import Dict, List, Tuple, Optional, Any, Union
from dataclasses import dataclass
import hashlib
# Type aliases
ZRElement = Any
GElement = Any
ECGroupType = Any
PartyId = int
@dataclass
class GG18_Signature:
"""GG18 threshold ECDSA signature (r, s)."""
r: ZRElement
s: ZRElement
def to_tuple(self) -> Tuple[ZRElement, ZRElement]:
return (self.r, self.s)
class GG18_Sign:
"""
GG18 4-round interactive signing protocol.
Uses Paillier-based MtA for multiplicative-to-additive conversion.
No presigning - all 4 rounds required for each signature.
>>> from charm.toolbox.eccurve import secp256k1
>>> from charm.toolbox.integergroup import RSAGroup
>>> group = ECGroup(secp256k1)
>>> rsa_group = RSAGroup()
>>> signer = GG18_Sign(group, rsa_group, paillier_bits=512) # Small for test
>>> g = group.random(G)
>>> signer is not None
True
"""
def __init__(self, ec_group: ECGroupType, rsa_group: RSAGroup,
paillier_bits: int = 2048):
"""
Initialize GG18 signing protocol.
Args:
ec_group: EC group (e.g., ECGroup(secp256k1))
rsa_group: RSA group for Paillier
paillier_bits: Paillier modulus bit length
"""
self.group = ec_group
self.rsa_group = rsa_group
self.order = int(ec_group.order())
self._mta = PaillierMtA(rsa_group, self.order, paillier_bits)
def _hash_message(self, message: bytes) -> ZRElement:
"""Hash message to curve scalar using SHA-256."""
h = hashlib.sha256(message).digest()
h_int = int.from_bytes(h, 'big') % self.order
return self.group.init(ZR, h_int)
def _hash_commitment(self, value: GElement) -> bytes:
"""Compute commitment hash for round 1."""
h = hashlib.sha256()
h.update(b"GG18_COMMIT:")
h.update(self.group.serialize(value))
return h.digest()
def sign_round1(self, party_id: PartyId, key_share: GG18_KeyShare,
participants: List[PartyId], generator: GElement,
session_id: bytes) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""
Round 1: Generate k_i, gamma_i, broadcast commitment.
Args:
party_id: This party's identifier
key_share: Party's key share from DKG
participants: List of participating parties (t parties)
generator: EC generator point g
session_id: Unique signing session identifier
Returns:
Tuple of (broadcast_msg, private_state)
"""
# Sample random values
k_i = self.group.random(ZR)
gamma_i = self.group.random(ZR)
# Compute Gamma_i = g^{gamma_i}
Gamma_i = generator ** gamma_i
# Commitment to Gamma_i
commitment = self._hash_commitment(Gamma_i)
broadcast_msg = {
'party_id': party_id,
'session_id': session_id,
'commitment': commitment,
}
private_state = {
'party_id': party_id,
'session_id': session_id,
'k_i': k_i,
'gamma_i': gamma_i,
'Gamma_i': Gamma_i,
'generator': generator,
'participants': participants,
'key_share': key_share,
}
return broadcast_msg, private_state
def sign_round2(self, party_id: PartyId, private_state: Dict[str, Any],
all_round1_msgs: List[Dict[str, Any]],
message: bytes) -> Tuple[Dict[str, Any], Dict[PartyId, Dict], Dict[str, Any]]:
"""
Round 2: Reveal Gamma_i, start MtA protocols.
Each party sends:
- Broadcast: Gamma_i reveal
- P2P: Enc(k_i) to each other party for MtA (k*gamma and k*x)
- P2P: gamma_i and x_i for the other party to use in their MtA
Args:
party_id: This party's identifier
private_state: State from round 1
all_round1_msgs: Commitments from all parties
message: Message to sign
Returns:
Tuple of (broadcast_msg, p2p_msgs, updated_state)
"""
Gamma_i = private_state['Gamma_i']
k_i = private_state['k_i']
gamma_i = private_state['gamma_i']
key_share = private_state['key_share']
participants = private_state['participants']
# Store message hash
e = self._hash_message(message)
# Verify commitments from other parties
round1_by_party = {msg['party_id']: msg for msg in all_round1_msgs}
# Broadcast reveal of Gamma_i
broadcast_msg = {
'party_id': party_id,
'Gamma_i': Gamma_i,
}
# Prepare MtA messages for each other party
# For MtA: party i sends Enc(k_i) under their own key
# Other party will compute response using their gamma_j/x_j
p2p_msgs = {}
mta_sender_states = {}
for other_id in participants:
if other_id != party_id:
# MtA sender step: encrypt k_i under MY Paillier key
# Other party will compute homomorphically and send response back
mta_msg_kg = self._mta.sender_round1(int(k_i), key_share.paillier)
mta_msg_kx = self._mta.sender_round1(int(k_i), key_share.paillier)
p2p_msgs[other_id] = {
'from': party_id,
'mta_k_gamma': mta_msg_kg,
'mta_k_x': mta_msg_kx,
# Include this party's gamma and x for the OTHER party's MtA receiver step
'gamma_i': int(gamma_i),
'x_i': int(key_share.x_i),
}
mta_sender_states[other_id] = {
'k_gamma_sender': mta_msg_kg,
'k_x_sender': mta_msg_kx,
}
# Update state
updated_state = private_state.copy()
updated_state['e'] = e
updated_state['mta_sender_states'] = mta_sender_states
updated_state['round1_by_party'] = round1_by_party
return broadcast_msg, p2p_msgs, updated_state
def sign_round3(self, party_id: PartyId, private_state: Dict[str, Any],
all_round2_broadcasts: List[Dict[str, Any]],
received_p2p_msgs: Dict[PartyId, Dict]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""
Round 3: Verify reveals, complete MtA, broadcast delta_i.
For MtA(k_i, gamma_j): party i is sender, party j is receiver
- Party i sent Enc(k_i) in round 2
- Party j received it and will compute response
- Party j gets beta_j, party i gets alpha_i
Since we need alpha_i (from MtA where I'm sender) and beta_i (from MtA where I'm receiver),
but in a single P2P round we can only do one direction, we use a simplified approach:
For a 2-party MtA(a, b) where result is a*b:
- We split it as: party_i computes k_i * gamma_j_received_from_j
- This gives correct additive shares when summed across all parties
Args:
party_id: This party's identifier
private_state: State from round 2
all_round2_broadcasts: Gamma reveals from all parties
received_p2p_msgs: P2P MtA messages received
Returns:
Tuple of (broadcast_msg, updated_state)
"""
k_i = private_state['k_i']
gamma_i = private_state['gamma_i']
key_share = private_state['key_share']
participants = private_state['participants']
round1_by_party = private_state['round1_by_party']
# Verify Gamma reveals match commitments
round2_by_party = {msg['party_id']: msg for msg in all_round2_broadcasts}
for pid in participants:
Gamma = round2_by_party[pid]['Gamma_i']
expected_commit = self._hash_commitment(Gamma)
if round1_by_party[pid]['commitment'] != expected_commit:
raise ValueError(f"Party {pid} commitment mismatch")
# Compute R = product of all Gamma_i
R = round2_by_party[participants[0]]['Gamma_i']
for pid in participants[1:]:
R = R * round2_by_party[pid]['Gamma_i']
# Simplified MtA: compute additive share of k*gamma and k*x locally
# Each party computes their share of the sum:
# sum(k_i * gamma_i) for all i = k * gamma where k = sum(k_i), gamma = sum(gamma_i)
#
# For threshold signing, we need k^{-1} * x, which we'll get by:
# - delta = k * gamma (used to compute R, then inverted)
# - sigma = k * x (the private key component)
#
# Party i's contribution:
# - delta_i: k_i * gamma_i + sum over j≠i of (cross terms)
# - sigma_i: k_i * x_i + sum over j≠i of (cross terms)
#
# In the simplified version (semi-honest), we use:
# - delta_i = k_i * (sum of all gamma from broadcasts)
# - But we need to be careful about the reconstruction
# Actually, the correct approach for threshold is:
# Each party holds a share x_i of x, and in signing:
# - k = sum(k_i) (random, generated freshly)
# - gamma = sum(gamma_i) (random, generated freshly, used for R = g^{gamma^{-1}})
# - Wait, that's not right either...
# Let me reconsider: In GG18:
# - Each party samples k_i, gamma_i
# - Gamma_i = g^{gamma_i}
# - R = product(Gamma_i) = g^{sum(gamma_i)} = g^{gamma}
# - Then delta = k * gamma = sum_i sum_j (k_i * gamma_j)
# - Each party computes delta_i = sum_j (k_i * gamma_j) = k_i * gamma (where gamma = sum gamma_j)
# - Then sum(delta_i) = sum_i(k_i * gamma) = k * gamma = delta ✓
# So each party needs to know sum of all gamma values!
# From broadcasts, we have Gamma_j = g^{gamma_j}, but not gamma_j directly
# In the P2P messages, I added gamma_j values, so we can use those
# Compute sum of all gamma values (including own)
gamma_sum = int(gamma_i)
for other_id in participants:
if other_id != party_id and other_id in received_p2p_msgs:
gamma_sum = (gamma_sum + received_p2p_msgs[other_id]['gamma_i']) % self.order
# Compute x = sum of all x_j * lambda_j (Lagrange interpolation at 0)
# Each party contributes x_j * lambda_j to reconstruct x
x_total = 0
for pid in participants:
if pid == party_id:
x_j = int(key_share.x_i)
else:
x_j = received_p2p_msgs[pid]['x_i']
lambda_j = self._compute_lagrange_coeff(pid, participants)
x_total = (x_total + x_j * lambda_j) % self.order
# delta_i = k_i * gamma (where gamma = sum of all gamma_j)
delta_i_int = (int(k_i) * gamma_sum) % self.order
delta_i = self.group.init(ZR, delta_i_int)
# sigma_i = k_i * x (party i's additive share of k*x)
# When summed: sum(sigma_i) = sum(k_i) * x = k * x
sigma_i_int = (int(k_i) * x_total) % self.order
sigma_i = self.group.init(ZR, sigma_i_int)
broadcast_msg = {
'party_id': party_id,
'delta_i': delta_i,
}
updated_state = private_state.copy()
updated_state['R'] = R
updated_state['delta_i'] = delta_i
updated_state['sigma_i'] = sigma_i
updated_state['round2_by_party'] = round2_by_party
updated_state['gamma_sum'] = gamma_sum
return broadcast_msg, updated_state
def _compute_lagrange_coeff(self, party_id: PartyId,
participants: List[PartyId]) -> int:
"""Compute Lagrange coefficient for a party in a set of participants."""
# lambda_i = product_{j != i} (0 - j) / (i - j) = product_{j != i} (-j) / (i - j)
# = product_{j != i} j / (j - i)
lambda_i = 1
for j in participants:
if j != party_id:
num = j
denom = j - party_id
# Compute modular inverse of denom
denom_inv = pow(denom % self.order, self.order - 2, self.order)
lambda_i = (lambda_i * num * denom_inv) % self.order
return lambda_i
def sign_round4(self, party_id: PartyId, private_state: Dict[str, Any],
all_round3_msgs: List[Dict[str, Any]]) -> Tuple[ZRElement, Dict[str, Any]]:
"""
Round 4: Compute and return signature share.
GG18 Signature Formula:
- delta = k * gamma = sum(delta_i)
- R_raw = g^gamma (from round 3)
- R = R_raw^{delta^{-1}} = g^{gamma / (k*gamma)} = g^{1/k}
- r = x-coordinate of R (mod q)
- sigma = k * x = sum(sigma_i)
- s = (e + r*x) * k^{-1}
- Since we have k*x (sigma) and k*gamma (delta):
- s = (e + r*sigma/k) * k^{-1} = e/k + r*sigma/k^2
- Hmm, this doesn't simplify nicely with what we have...
Actually in GG18:
- s_i = k_i * e + r * sigma_i (where sigma_i is party i's share of k*x)
- s = sum(s_i) / delta = (k*e + r*k*x) / (k*gamma) = (e + rx) / gamma
- But we want s = k^{-1}(e + rx), and R = g^{1/k}
- So we need R = g^gamma and s = (e + rx) * gamma / delta
- Since delta = k*gamma, s = (e + rx) / k ✓
Args:
party_id: This party's identifier
private_state: State from round 3
all_round3_msgs: Delta broadcasts from all parties
Returns:
Tuple of (signature_share, proof)
"""
e = private_state['e']
R_raw = private_state['R'] # g^{sum(gamma_i)} = g^gamma
sigma_i = private_state['sigma_i']
k_i = private_state['k_i']
participants = private_state['participants']
# Compute delta = sum of all delta_i = k * gamma
delta = self.group.init(ZR, 0)
for msg in all_round3_msgs:
delta = delta + msg['delta_i']
# Compute delta inverse
delta_inv = delta ** -1
# R = g^{gamma} * delta^{-1} = g^{gamma / (k*gamma)} = g^{1/k}
# This gives us the correct R for ECDSA where R = g^{k^{-1}} (or equivalently k*G in additive notation)
# Actually wait - in standard ECDSA, R = k*G = g^k, not g^{1/k}
# So R_raw = g^gamma, and we need R = g^{1/k}
# Since delta = k*gamma, delta^{-1} = 1/(k*gamma)
# R = R_raw^{delta^{-1}} = g^{gamma * 1/(k*gamma)} = g^{1/k}
# But standard ECDSA uses R = g^k... let me re-read GG18
#
# In GG18 Section 4.2: R = g^{delta^{-1}} where delta = k*gamma
# and gamma = product of all Gamma_i where Gamma_i = g^{gamma_i}
# So R = (product Gamma_i)^{delta^{-1}} = g^{gamma * delta^{-1}} = g^{1/k}
#
# Then r = H(R) (x-coordinate)
# s = k(e + rx) = delta/gamma * (e + rx)
#
# For verification: g^{s^{-1} * e} * pk^{s^{-1} * r} = R
# = g^{(e+rx)/s} = g^{(e+rx) * gamma / (delta*(e+rx))} = g^{gamma/delta} = g^{1/k} = R ✓
R = R_raw ** delta_inv
# Compute r = x-coordinate of R (mod q)
r = self.group.zr(R)
# Compute signature share
# s = k(e + rx) = (e*k + r*k*x) = e*sum(k_i) + r*sum(sigma_i)
# Each party computes: s_i = e*k_i + r*sigma_i
s_i = (e * k_i) + (r * sigma_i)
proof = {
'party_id': party_id,
'R': R,
'r': r,
'delta': delta,
}
return s_i, proof
def combine_signatures(self, signature_shares: Dict[PartyId, ZRElement],
R: GElement, participants: List[PartyId]) -> GG18_Signature:
"""
Combine signature shares into final signature.
Args:
signature_shares: Dict mapping party_id to signature share
R: Combined R point from round 3
participants: List of participating parties
Returns:
GG18_Signature object with (r, s)
"""
r = self.group.zr(R)
# Sum signature shares (additive reconstruction)
s = self.group.init(ZR, 0)
for party_id in participants:
if party_id in signature_shares:
s = s + signature_shares[party_id]
# Low-s normalization
s = self._normalize_s(s)
return GG18_Signature(r=r, s=s)
def _normalize_s(self, s: ZRElement) -> ZRElement:
"""Normalize s to low-s form (BIP-62 compliant)."""
s_int = int(s) % self.order
half_order = self.order // 2
if s_int > half_order:
return self.group.init(ZR, self.order - s_int)
return s
def verify(self, public_key: GElement, signature: GG18_Signature,
message: bytes, generator: GElement) -> bool:
"""
Verify ECDSA signature.
Args:
public_key: Combined public key
signature: GG18_Signature to verify
message: Original message
generator: EC generator point
Returns:
True if valid, False otherwise
"""
r, s = signature.r, signature.s
e = self._hash_message(message)
# s^{-1}
s_inv = s ** -1
# u1 = e * s^{-1}, u2 = r * s^{-1}
u1 = e * s_inv
u2 = r * s_inv
# R' = g^{u1} * pk^{u2}
R_prime = (generator ** u1) * (public_key ** u2)
# r' = x-coordinate of R'
r_prime = self.group.zr(R_prime)
return r == r_prime
[docs]
class GG18(PKSig):
"""
GG18 Threshold ECDSA Signature Scheme.
Implements the full threshold ECDSA protocol from Gennaro & Goldfeder 2019.
Extends PKSig base class with keygen(), sign(), verify() interface.
Features:
- t-of-n threshold signatures
- Paillier-based MtA protocol
- 4-round interactive signing
- No presigning (each signature requires full protocol)
Security:
- Assumption: DDH, DCR (Decisional Composite Residuosity), ROM
- Definition: EU-CMA (Existential Unforgeability under Chosen Message Attack)
>>> from charm.toolbox.eccurve import secp256k1
>>> from charm.toolbox.integergroup import RSAGroup
>>> group = ECGroup(secp256k1)
>>> rsa_group = RSAGroup()
>>> gg18 = GG18(group, rsa_group, threshold=2, num_parties=3, paillier_bits=512)
>>> gg18 is not None
True
"""
def __init__(self, ec_group: ECGroupType, rsa_group: RSAGroup,
threshold: int, num_parties: int, paillier_bits: int = 2048):
"""
Initialize GG18 threshold ECDSA.
Args:
ec_group: EC group (e.g., ECGroup(secp256k1))
rsa_group: RSA group for Paillier
threshold: Minimum parties to sign (t)
num_parties: Total parties (n)
paillier_bits: Paillier modulus bit length
"""
PKSig.__init__(self)
self.setProperty(secDef='EU_CMA', assumption='DDH+DCR',
messageSpace='arbitrary', secModel='ROM')
self.group = ec_group
self.rsa_group = rsa_group
self.t = threshold
self.n = num_parties
self.paillier_bits = paillier_bits
self._dkg = GG18_DKG(ec_group, rsa_group, threshold, num_parties, paillier_bits)
self._signer = GG18_Sign(ec_group, rsa_group, paillier_bits)
[docs]
def keygen(self, generator: Optional[GElement] = None) -> Tuple[GElement, List[GG18_KeyShare]]:
"""
Generate threshold key shares.
This is a convenience wrapper that simulates the 3-round DKG.
In practice, each party runs the DKG rounds interactively.
Args:
generator: EC generator point (uses random if None)
Returns:
Tuple of (public_key, list of key shares)
"""
if generator is None:
generator = self.group.random(G)
session_id = b"GG18_KEYGEN_" + self.group.serialize(generator)[:16]
# Round 1: All parties generate secrets and commitments
round1_results = []
for i in range(1, self.n + 1):
msg, state = self._dkg.keygen_round1(i, generator, session_id)
round1_results.append((msg, state))
round1_msgs = [r[0] for r in round1_results]
states = [r[1] for r in round1_results]
# Round 2: Prepare shares
round2_results = []
for i in range(self.n):
shares, state = self._dkg.keygen_round2(i + 1, states[i], round1_msgs)
round2_results.append((shares, state))
states[i] = state
# Collect shares for each party
received_shares = {}
for recv in range(1, self.n + 1):
received_shares[recv] = {}
for send in range(self.n):
received_shares[recv][send + 1] = round2_results[send][0][recv]
# Round 3: Compute final key shares
key_shares = []
for i in range(self.n):
key_share, complaint = self._dkg.keygen_round3(
i + 1, states[i], received_shares[i + 1], round1_msgs
)
if complaint is not None:
raise ValueError(f"DKG failed: complaint from party {complaint['accuser']}")
key_shares.append(key_share)
public_key = key_shares[0].X
return public_key, key_shares
[docs]
def sign(self, key_shares: List[GG18_KeyShare], message: bytes,
participants: Optional[List[PartyId]] = None,
generator: Optional[GElement] = None) -> GG18_Signature:
"""
Generate threshold signature.
Convenience wrapper that simulates the 4-round signing protocol.
In practice, each party runs the signing rounds interactively.
Args:
key_shares: List of participating parties' key shares
message: Message to sign
participants: List of participating party IDs (default: first t)
generator: EC generator point
Returns:
GG18_Signature object
"""
if len(key_shares) < self.t:
raise ValueError(f"Need at least {self.t} key shares")
if participants is None:
participants = [ks.party_id for ks in key_shares[:self.t]]
if generator is None:
# Derive from first key share's verification key
generator = self.group.random(G)
session_id = b"GG18_SIGN_" + hashlib.sha256(message).digest()[:16]
# Get key shares for participants
ks_by_party = {ks.party_id: ks for ks in key_shares}
# Round 1
round1_results = {}
states = {}
for pid in participants:
msg, state = self._signer.sign_round1(
pid, ks_by_party[pid], participants, generator, session_id
)
round1_results[pid] = msg
states[pid] = state
round1_msgs = list(round1_results.values())
# Round 2
round2_broadcasts = {}
round2_p2p = {}
for pid in participants:
broadcast, p2p, state = self._signer.sign_round2(
pid, states[pid], round1_msgs, message
)
round2_broadcasts[pid] = broadcast
round2_p2p[pid] = p2p
states[pid] = state
round2_msgs = list(round2_broadcasts.values())
# Collect P2P messages for each party
received_p2p = {}
for recv_pid in participants:
received_p2p[recv_pid] = {}
for send_pid in participants:
if send_pid != recv_pid and recv_pid in round2_p2p[send_pid]:
received_p2p[recv_pid][send_pid] = round2_p2p[send_pid][recv_pid]
# Round 3
round3_results = {}
for pid in participants:
broadcast, state = self._signer.sign_round3(
pid, states[pid], round2_msgs, received_p2p[pid]
)
round3_results[pid] = broadcast
states[pid] = state
round3_msgs = list(round3_results.values())
# Round 4
signature_shares = {}
R = None
for pid in participants:
s_i, proof = self._signer.sign_round4(pid, states[pid], round3_msgs)
signature_shares[pid] = s_i
if R is None:
R = proof['R']
# Combine signatures
return self._signer.combine_signatures(signature_shares, R, participants)
[docs]
def verify(self, public_key: GElement, message: bytes,
signature: Union[GG18_Signature, Tuple[ZRElement, ZRElement]],
generator: Optional[GElement] = None) -> bool:
"""
Verify ECDSA signature.
Args:
public_key: Combined public key
message: Original message
signature: GG18_Signature or (r, s) tuple
generator: EC generator point
Returns:
True if valid, False otherwise
"""
if generator is None:
generator = self.group.random(G)
if isinstance(signature, tuple):
signature = GG18_Signature(r=signature[0], s=signature[1])
return self._signer.verify(public_key, signature, message, generator)