From 5b208214d1ef356aae1ef1933d15c265a4fe2dba Mon Sep 17 00:00:00 2001 From: Jonas Nick Date: Wed, 4 Dec 2024 09:34:03 +0000 Subject: [PATCH] Prevent malicious taproot commitment --- README.md | 18 ++++++++++++++---- python/chilldkg_ref/chilldkg.py | 2 ++ python/chilldkg_ref/simplpedpop.py | 26 ++++++++++++++++---------- python/chilldkg_ref/vss.py | 29 ++++++++++++++++++++++++++++- 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 61452f9..c821a9f 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,11 @@ which is common to all participants and does not need to be kept confidential. Recovering a device that has participated in a DKG session then requires just the device's host secret key and the recovery data, the latter of which can be obtained from any cooperative participant (or the coordinator) or from an untrusted backup provider. +ChillDKG outputs a threshold public key that can be safely used in [[BIP 341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki)] Taproot outputs. +In contrast, a standard PedPop implementation would allow a malicious participant to secretly embed a Taproot commitment to a BIP 341 script path within the threshold public key. +If such a key was used directly in a Taproot output, the malicious participant could spend the output through their hidden script path, bypassing the requirement for `t - 1` additional signatures. +While BIP 341 outlines special precautions for using threshold public keys generated by standard PedPop, ChillDKG eliminates this vulnerability entirely, providing built-in protection against accidental misuse. + These features make ChillDKG usable in a wide range of applications. As a consequence of this broad applicability, there will necessarily be scenarios in which specialized protocols need less communication overhead and fewer rounds, e.g., when setting up multiple signing devices in a single location. @@ -131,6 +136,7 @@ In summary, we aim for the following design goals: - **Simple backups**: ChillDKG allows recovering the DKG output using the host secret key and common recovery data shared among all participants and the coordinator. This eliminates the need for session-specific backups, simplifying user experience. - **Untrusted coordinator**: Like FROST, ChillDKG uses a coordinator that relays messages between the participants. This simplifies the network topology, and the coordinator additionally reduces communication overhead by aggregating some of the messages. A malicious coordinator can force the DKG to fail but cannot negatively affect the security of the DKG. - **Per-participant public keys**: When ChillDKG is used with FROST, partial signature verification is supported. + - **Taproot-safe threshold public key**: ChillDKG prevents malicious participants from embedding hidden [[BIP 341]](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki)] Taproot commitment to a script path in the threshold public key. In summary, ChillDKG incorporates solutions for both secure channels and consensus and simplifies backups in practice. As a result, it fits a wide range of application scenarios, @@ -241,21 +247,25 @@ Our variant of the SimplPedPop protocol then works as follows: the vector `sum_coms` is now the complete component-wise sum of the `coms[j]` vectors from every participant `j`. It acts as a VSS commitment to the sum `f = f_0 + ... + f_{n-1}` of the polynomials of all participants.) + To prevent malicious participants from embedding a [[BIP 341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki)] Taproot script path in the threshold public key, + each participant creates a modified VSS commitment called `sum_coms_tweaked` from `sum_coms`, such that the public key generated from `sum_coms_tweaked` has an unspendable script path. + This is achieved by computing a Taproot tweak `t` for an unspendable script path and adding the point `t * G` to `sum_coms[0]`. + Participant `i` computes the public share of every participant `j` as follows: ``` - pubshares[j] = (j+1)^0 * sum_coms[0] + ... + (j+1)^(t-1) * sum_coms[t-1] + pubshares[j] = (j+1)^0 * sum_coms_tweaked[0] + ... + (j+1)^(t-1) * sum_coms_tweaked[t-1] ``` - Let `secshare` be the sum of VSS shares privately obtained from each participant. - Participant `i` checks the validity of `secshare` against `sum_coms` + Let `secshare` be the sum of VSS shares privately obtained from each participant and Taproot tweak `t`. + Participant `i` checks the validity of `secshare` against `sum_coms_tweaked` by checking if the equation `secshare * G = pubshares[i]` holds. (Assuming `secshare` is the sum of the VSS shares created by other participants, it will be equal to `f(i+1)`.) If the check fails, participant `i` aborts. Otherwise, participant `i` sets the DKG output consisting of this participant's secret share `secshare`, - the threshold public key `threshold_pubkey = sum_coms[0]`, and + the threshold public key `threshold_pubkey = sum_coms_tweaked[0]`, and all participants' public shares `pubshares`. As a final step, participant `i` enters a session of an external equality check protocol diff --git a/python/chilldkg_ref/chilldkg.py b/python/chilldkg_ref/chilldkg.py index 825e25e..c2cb920 100644 --- a/python/chilldkg_ref/chilldkg.py +++ b/python/chilldkg_ref/chilldkg.py @@ -684,6 +684,7 @@ def recover( certeq_verify(hostpubkeys, eq_input, cert) # Compute threshold pubkey and individual pubshares + sum_coms, secshare_tweak = sum_coms.invalid_taproot_commit() threshold_pubkey = sum_coms.commitment_to_secret() pubshares = [sum_coms.pubshare(i) for i in range(n)] @@ -706,6 +707,7 @@ def recover( idx, enc_secshares[idx], ) + secshare += secshare_tweak # This is just a sanity check. Our signature is valid, so we have done # this check already during the actual session. diff --git a/python/chilldkg_ref/simplpedpop.py b/python/chilldkg_ref/simplpedpop.py index 17113cc..3790c7e 100644 --- a/python/chilldkg_ref/simplpedpop.py +++ b/python/chilldkg_ref/simplpedpop.py @@ -2,7 +2,7 @@ from typing import List, NamedTuple, NewType, Tuple, Optional, NoReturn from secp256k1proto.bip340 import schnorr_sign, schnorr_verify -from secp256k1proto.secp256k1 import GE, Scalar +from secp256k1proto.secp256k1 import G, GE, Scalar from .util import ( BIP_TAG, SecretKeyError, @@ -112,6 +112,7 @@ class ParticipantBlameState(NamedTuple): n: int idx: int secshare: Scalar + secshare_tweak: Scalar pubshare: GE @@ -204,16 +205,20 @@ def participant_step2( i, "Participant sent invalid proof-of-knowledge" ) sum_coms = assemble_sum_coms(coms_to_secrets, sum_coms_to_nonconst_terms) - threshold_pubkey = sum_coms.commitment_to_secret() - pubshare = sum_coms.pubshare(idx) + sum_coms_tweaked, secshare_tweak = sum_coms.invalid_taproot_commit() + secshare += secshare_tweak + threshold_pubkey = sum_coms_tweaked.commitment_to_secret() + pubshare = sum_coms_tweaked.pubshare(idx) if not VSSCommitment.verify_secshare(secshare, pubshare): raise UnknownFaultyParticipantOrCoordinatorError( - ParticipantBlameState(n, idx, secshare, pubshare), + ParticipantBlameState(n, idx, secshare, secshare_tweak, pubshare), "Received invalid secshare, consider blaming to determine faulty party", ) - pubshares = [sum_coms.pubshare(i) if i != idx else pubshare for i in range(n)] + pubshares = [ + sum_coms_tweaked.pubshare(i) if i != idx else pubshare for i in range(n) + ] dkg_output = DKGOutput( secshare.to_bytes(), threshold_pubkey.to_bytes_compressed(), @@ -228,13 +233,13 @@ def participant_blame( cblame: CoordinatorBlameMsg, partial_secshares: List[Scalar], ) -> NoReturn: - n, idx, secshare, pubshare = blame_state + n, idx, secshare, secshare_tweak, pubshare = blame_state partial_pubshares = cblame.partial_pubshares - if GE.sum(*partial_pubshares) != pubshare: + if GE.sum(*partial_pubshares) + secshare_tweak * G != pubshare: raise FaultyCoordinatorError("Sum of partial pubshares not equal to pubshare") - if Scalar.sum(*partial_secshares) != secshare: + if Scalar.sum(*partial_secshares) + secshare_tweak != secshare: raise SecshareSumError("Sum of partial secshares not equal to secshare") for i in range(n): @@ -286,8 +291,9 @@ def coordinator_step( cmsg = CoordinatorMsg(coms_to_secrets, sum_coms_to_nonconst_terms, pops) sum_coms = assemble_sum_coms(coms_to_secrets, sum_coms_to_nonconst_terms) - threshold_pubkey = sum_coms.commitment_to_secret() - pubshares = [sum_coms.pubshare(i) for i in range(n)] + sum_coms_tweaked, secshare_tweak = sum_coms.invalid_taproot_commit() + threshold_pubkey = sum_coms_tweaked.commitment_to_secret() + pubshares = [sum_coms_tweaked.pubshare(i) for i in range(n)] dkg_output = DKGOutput( None, diff --git a/python/chilldkg_ref/vss.py b/python/chilldkg_ref/vss.py index cee7102..2bd1b5d 100644 --- a/python/chilldkg_ref/vss.py +++ b/python/chilldkg_ref/vss.py @@ -1,8 +1,9 @@ from __future__ import annotations -from typing import List +from typing import List, Tuple from secp256k1proto.secp256k1 import GE, G, Scalar +from secp256k1proto.util import tagged_hash from .util import tagged_hash_bip_dkg @@ -74,6 +75,32 @@ def commitment_to_secret(self) -> GE: def commitment_to_nonconst_terms(self) -> List[GE]: return self.ges[1 : self.t()] + def invalid_taproot_commit(self) -> Tuple[VSSCommitment, Scalar]: + # Return a modified VSS commitment such that the threshold public key + # generated from it has an unspendable BIP 341 Taproot script path. + # + # Specifically, for a VSS commitment `com`, we have: + # `com.add_invalid_taproot_commitment().commitment_to_secret() = com.commitment_to_secret() + t*G`. + # + # The tweak 't' commits to an empty message, which is invalid according + # to BIP 341 for Taproot script spends. This follows BIP 341's + # recommended approach for committing to an unspendable script path. + # + # This prevents a malicious participant from secretly inserting a *valid* + # Taproot commitment to a script path into the summed VSS commitment during + # the DKG protocol. If the resulting threshold public key was used directly + # in a BIP 341 Taproot output, the malicious participant would be able to + # spend the output using their hidden script path. + # + # The function returns the updated VSS commitment and the tweak `t` which + # must be added to all secret shares of the commitment. + pk = self.commitment_to_secret() + secshare_tweak = Scalar.from_bytes( + tagged_hash("TapTweak", pk.to_bytes_compressed()) + ) + vss_tweak = VSSCommitment([secshare_tweak * G] + [GE()] * (self.t() - 1)) + return (self + vss_tweak, secshare_tweak) + class VSS: f: Polynomial