Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RecPedPop: use ECDH between hostkeys to drop the enckeys round #15

Merged
merged 1 commit into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 16 additions & 51 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,36 +320,21 @@ def encrypt(share: Scalar, my_deckey: bytes, enckey: bytes, context: bytes) -> S

The participants start by generating an ephemeral key pair as per [BIP 327's IndividualPubkey](https://github.com/bitcoin/bips/blob/master/bip-0327.mediawiki#key-generation-of-an-individual-signer) algorithm for encrypting the 32-byte key shares.

```python
EncPedPopR1State = Tuple[bytes, bytes]

def encpedpop_round1(seed: bytes) -> Tuple[EncPedPopR1State, bytes]:
my_deckey = kdf(seed, "deckey")
my_enckey = pubkey_gen_plain(my_deckey)
state1 = (my_deckey, my_enckey)
return state1, my_enckey
```

The (public) encryption keys are distributed among the participants.

```python
EncPedPopR2State = Tuple[int, bytes, List[bytes], SimplPedPopR1State]

def encpedpop_round2(seed: bytes, state1: EncPedPopR1State, t: int, n: int, enckeys: List[bytes]) -> Tuple[EncPedPopR2State, VSSCommitmentExt, List[Scalar]]:
def encpedpop_round2(seed: bytes, t: int, n: int, my_deckey: bytes, enckeys: List[bytes], my_idx: int) -> Tuple[EncPedPopR2State, VSSCommitmentExt, List[Scalar]]:
assert(n == len(enckeys))
if len(enckeys) != len(set(enckeys)):
raise DuplicateEnckeysError

my_deckey, my_enckey = state1
# Protect against reuse of seed in case we previously exported shares
# encrypted under wrong enckeys.
assert(t < 2**(4*8))
enc_context = t.to_bytes(4, byteorder="big") + b''.join(enckeys)
seed_ = tagged_hash_bip_dkg("EncPedPop seed", seed + enc_context)
try:
my_idx = enckeys.index(my_enckey)
except ValueError:
raise BadCoordinatorError("Coordinator sent list of encryption keys that does not contain our key.")
simpl_state, vss_commitment_ext, gen_shares = simplpedpop_round1(seed_, t, n, my_idx)
enc_gen_shares = [encrypt(gen_shares[i], my_deckey, enckeys[i], enc_context) for i in range(n)]
state2 = (t, my_deckey, enckeys, simpl_state)
Expand Down Expand Up @@ -393,7 +378,7 @@ Generate long-term host keys.
def recpedpop_hostpubkey(seed: bytes) -> Tuple[bytes, bytes]:
my_hostsigkey = kdf(seed, "hostsigkey")
# TODO: rename to distinguish plain and xonly key gen
my_hostverkey = pubkey_gen(my_hostsigkey)
my_hostverkey = pubkey_gen_plain(my_hostsigkey)
return (my_hostsigkey, my_hostverkey)
```

Expand All @@ -413,30 +398,17 @@ def recpedpop_setup_id(hostverkeys: List[bytes], t: int, context_string: bytes)
The participants compare the setup identifier with every other participant out-of-band.
If some other participant presents a different setup identifier, the participant aborts.

```python
RecPedPopR1State = Tuple[int, int, bytes, EncPedPopR1State, bytes]

def recpedpop_round1(seed: bytes, setup: Setup) -> Tuple[RecPedPopR1State, bytes]:
hostverkeys, t, setup_id = setup

# Derive setup-dependent seed
seed_ = kdf(seed, "setup", setup_id)

n = len(hostverkeys)
enc_state1, my_enckey = encpedpop_round1(seed_)
state1 = (t, n, setup_id, enc_state1, my_enckey)
return state1, my_enckey
```

```python
RecPedPopR2State = Tuple[bytes, int, EncPedPopR2State]

def recpedpop_round2(seed: bytes, state1: RecPedPopR1State, enckeys: List[bytes]) -> Tuple[RecPedPopR2State, VSSCommitmentExt, List[Scalar]]:
t, n, setup_id, enc_state1, my_enckey = state1
def recpedpop_round2(seed: bytes, setup: Setup) -> Tuple[RecPedPopR2State, VSSCommitmentExt, List[Scalar]]:
my_hostsigkey, my_hostverkey = recpedpop_hostpubkey(seed)
(hostverkeys, t, setup_id) = setup
n = len(hostverkeys)

seed_ = kdf(seed, "setup", setup_id)
enc_state2, vss_commitment_ext, enc_gen_shares = encpedpop_round2(seed_, enc_state1, t, n, enckeys)
my_idx = enckeys.index(my_enckey)
my_idx = hostverkeys.index(my_hostverkey)
enc_state2, vss_commitment_ext, enc_gen_shares = encpedpop_round2(seed_, t, n, my_hostsigkey, hostverkeys, my_idx)
state2 = (setup_id, my_idx, enc_state2)
return state2, vss_commitment_ext, enc_gen_shares
```
Expand All @@ -461,11 +433,8 @@ EqualityCheck = Callable[[bytes], Coroutine[Any, Any, bool]]

async def recpedpop(chan: SignerChannel, seed: bytes, my_hostsigkey: bytes, setup: Setup) -> Union[Tuple[DKGOutput, Any], bool]:
(hostverkeys, _, _) = setup
state1, my_enckey = recpedpop_round1(seed, setup)
chan.send(my_enckey)
enckeys = await chan.receive()

state2, vss_commitment_ext, enc_gen_shares = recpedpop_round2(seed, state1, enckeys)
state2, vss_commitment_ext, enc_gen_shares = recpedpop_round2(seed, setup)
chan.send((vss_commitment_ext, enc_gen_shares))
vss_commitments_sum, all_enc_shares_sum = await chan.receive()

Expand All @@ -475,7 +444,7 @@ async def recpedpop(chan: SignerChannel, seed: bytes, my_hostsigkey: bytes, setu
print("Exception", repr(e))
return False
cert = await certifying_eq(chan, my_hostsigkey, hostverkeys, eta)
transcript = (setup, enckeys, vss_commitments_sum, all_enc_shares_sum, cert)
transcript = (setup, vss_commitments_sum, all_enc_shares_sum, cert)
return (shares_sum, shared_pubkey, signer_pubkeys), transcript
```

Expand All @@ -490,7 +459,7 @@ def verify_cert(hostverkeys: List[bytes], x: bytes, sigs: List[bytes]) -> bool:
n = len(hostverkeys)
if len(sigs) != n:
return False
is_valid = [schnorr_verify(x, hostverkeys[i], sigs[i]) for i in range(n)]
is_valid = [schnorr_verify(x, hostverkeys[i][1:33], sigs[i]) for i in range(n)]
return all(is_valid)

async def certifying_eq(chan: SignerChannel, my_hostsigkey: bytes, hostverkeys: List[bytes], x: bytes) -> List[bytes]:
Expand All @@ -501,11 +470,12 @@ async def certifying_eq(chan: SignerChannel, my_hostsigkey: bytes, hostverkeys:
while(True):
i, ty, msg = await chan.receive()
if ty == "SIG":
is_valid = schnorr_verify(x, hostverkeys[i], msg)
# TODO: We're just slicing into a hostverkey to get a 32 byte BIP 340
# pubkey. This makes signatures sort of malleable. Is this ok?
is_valid = schnorr_verify(x, hostverkeys[i][1:33], msg)
if sigs[i] == b'' and is_valid:
sigs[i] = msg
elif not is_valid:
print("sig not valid for x", x)
# The signer `hpk` is either malicious or an honest signer
# whose input is not equal to `x`. This means that there is
# some malicious signer or that some messages have been
Expand All @@ -532,10 +502,6 @@ It may still be helpful to check with other participants out-of-band that they h

```python
async def recpedpop_coordinate(chans: CoordinatorChannels, t: int, n: int) -> None:
enckeys = []
for i in range(n):
enckeys += [await chans.receive_from(i)]
chans.send_all(enckeys)
vss_commitments_ext = []
all_enc_shares_sum = [0]*n
for i in range(n):
Expand Down Expand Up @@ -568,13 +534,12 @@ On the other hand, DKG transcripts are public and allow to re-run above ChillDKG
# Recovery requires the seed and the public transcript
def recpedpop_recover(seed: bytes, transcript: Any) -> Union[Tuple[DKGOutput, Setup], bool]:
_, my_hostverkey = recpedpop_hostpubkey(seed)
setup, enckeys, vss_commitments_sum, all_enc_shares_sum, cert = transcript
setup, vss_commitments_sum, all_enc_shares_sum, cert = transcript
hostverkeys, _, _ = setup
if not my_hostverkey in hostverkeys:
return False

state1, _ = recpedpop_round1(seed, setup)
state2, _, _ = recpedpop_round2(seed, state1, enckeys)
state2, _, _ = recpedpop_round2(seed, setup)

eta, (shares_sum, shared_pubkey, signer_pubkeys) = recpedpop_pre_finalize(seed, state2, vss_commitments_sum, all_enc_shares_sum)
if not verify_cert(hostverkeys, eta, cert):
Expand Down
1 change: 1 addition & 0 deletions reference/crypto_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def point_negate(P: Optional[Point]) -> Optional[Point]:

def cpoint(x: bytes) -> Point:
if len(x) != 33:
print("bla")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:)

raise ValueError('x is not a valid compressed point.')
P = lift_x(int_from_bytes(x[1:33]))
if P is None:
Expand Down
63 changes: 16 additions & 47 deletions reference/reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,31 +153,18 @@ def ecdh(deckey: bytes, enckey: bytes, context: bytes) -> Scalar:
def encrypt(share: Scalar, my_deckey: bytes, enckey: bytes, context: bytes) -> Scalar:
return (share + ecdh(my_deckey, enckey, context)) % GROUP_ORDER

EncPedPopR1State = Tuple[bytes, bytes]

def encpedpop_round1(seed: bytes) -> Tuple[EncPedPopR1State, bytes]:
my_deckey = kdf(seed, "deckey")
my_enckey = pubkey_gen_plain(my_deckey)
state1 = (my_deckey, my_enckey)
return state1, my_enckey

EncPedPopR2State = Tuple[int, bytes, List[bytes], SimplPedPopR1State]

def encpedpop_round2(seed: bytes, state1: EncPedPopR1State, t: int, n: int, enckeys: List[bytes]) -> Tuple[EncPedPopR2State, VSSCommitmentExt, List[Scalar]]:
def encpedpop_round2(seed: bytes, t: int, n: int, my_deckey: bytes, enckeys: List[bytes], my_idx: int) -> Tuple[EncPedPopR2State, VSSCommitmentExt, List[Scalar]]:
assert(n == len(enckeys))
if len(enckeys) != len(set(enckeys)):
raise DuplicateEnckeysError

my_deckey, my_enckey = state1
# Protect against reuse of seed in case we previously exported shares
# encrypted under wrong enckeys.
assert(t < 2**(4*8))
enc_context = t.to_bytes(4, byteorder="big") + b''.join(enckeys)
seed_ = tagged_hash_bip_dkg("EncPedPop seed", seed + enc_context)
try:
my_idx = enckeys.index(my_enckey)
except ValueError:
raise BadCoordinatorError("Coordinator sent list of encryption keys that does not contain our key.")
simpl_state, vss_commitment_ext, gen_shares = simplpedpop_round1(seed_, t, n, my_idx)
enc_gen_shares = [encrypt(gen_shares[i], my_deckey, enckeys[i], enc_context) for i in range(n)]
state2 = (t, my_deckey, enckeys, simpl_state)
Expand All @@ -201,7 +188,7 @@ def encpedpop_pre_finalize(state2: EncPedPopR2State, vss_commitments_sum: VSSCom
def recpedpop_hostpubkey(seed: bytes) -> Tuple[bytes, bytes]:
my_hostsigkey = kdf(seed, "hostsigkey")
# TODO: rename to distinguish plain and xonly key gen
my_hostverkey = pubkey_gen(my_hostsigkey)
my_hostverkey = pubkey_gen_plain(my_hostsigkey)
return (my_hostsigkey, my_hostverkey)

Setup = Tuple[List[bytes], int, bytes]
Expand All @@ -212,27 +199,16 @@ def recpedpop_setup_id(hostverkeys: List[bytes], t: int, context_string: bytes)
setup = (hostverkeys, t, setup_id)
return setup, setup_id

RecPedPopR1State = Tuple[int, int, bytes, EncPedPopR1State, bytes]

def recpedpop_round1(seed: bytes, setup: Setup) -> Tuple[RecPedPopR1State, bytes]:
hostverkeys, t, setup_id = setup

# Derive setup-dependent seed
seed_ = kdf(seed, "setup", setup_id)

n = len(hostverkeys)
enc_state1, my_enckey = encpedpop_round1(seed_)
state1 = (t, n, setup_id, enc_state1, my_enckey)
return state1, my_enckey

RecPedPopR2State = Tuple[bytes, int, EncPedPopR2State]

def recpedpop_round2(seed: bytes, state1: RecPedPopR1State, enckeys: List[bytes]) -> Tuple[RecPedPopR2State, VSSCommitmentExt, List[Scalar]]:
t, n, setup_id, enc_state1, my_enckey = state1
def recpedpop_round2(seed: bytes, setup: Setup) -> Tuple[RecPedPopR2State, VSSCommitmentExt, List[Scalar]]:
my_hostsigkey, my_hostverkey = recpedpop_hostpubkey(seed)
(hostverkeys, t, setup_id) = setup
n = len(hostverkeys)

seed_ = kdf(seed, "setup", setup_id)
enc_state2, vss_commitment_ext, enc_gen_shares = encpedpop_round2(seed_, enc_state1, t, n, enckeys)
my_idx = enckeys.index(my_enckey)
my_idx = hostverkeys.index(my_hostverkey)
enc_state2, vss_commitment_ext, enc_gen_shares = encpedpop_round2(seed_, t, n, my_hostsigkey, hostverkeys, my_idx)
state2 = (setup_id, my_idx, enc_state2)
return state2, vss_commitment_ext, enc_gen_shares

Expand All @@ -253,11 +229,8 @@ def recpedpop_pre_finalize(seed: bytes, state2: RecPedPopR2State, vss_commitment

async def recpedpop(chan: SignerChannel, seed: bytes, my_hostsigkey: bytes, setup: Setup) -> Union[Tuple[DKGOutput, Any], bool]:
(hostverkeys, _, _) = setup
state1, my_enckey = recpedpop_round1(seed, setup)
chan.send(my_enckey)
enckeys = await chan.receive()

state2, vss_commitment_ext, enc_gen_shares = recpedpop_round2(seed, state1, enckeys)
state2, vss_commitment_ext, enc_gen_shares = recpedpop_round2(seed, setup)
chan.send((vss_commitment_ext, enc_gen_shares))
vss_commitments_sum, all_enc_shares_sum = await chan.receive()

Expand All @@ -267,14 +240,14 @@ async def recpedpop(chan: SignerChannel, seed: bytes, my_hostsigkey: bytes, setu
print("Exception", repr(e))
return False
cert = await certifying_eq(chan, my_hostsigkey, hostverkeys, eta)
transcript = (setup, enckeys, vss_commitments_sum, all_enc_shares_sum, cert)
transcript = (setup, vss_commitments_sum, all_enc_shares_sum, cert)
return (shares_sum, shared_pubkey, signer_pubkeys), transcript

def verify_cert(hostverkeys: List[bytes], x: bytes, sigs: List[bytes]) -> bool:
n = len(hostverkeys)
if len(sigs) != n:
return False
is_valid = [schnorr_verify(x, hostverkeys[i], sigs[i]) for i in range(n)]
is_valid = [schnorr_verify(x, hostverkeys[i][1:33], sigs[i]) for i in range(n)]
return all(is_valid)

async def certifying_eq(chan: SignerChannel, my_hostsigkey: bytes, hostverkeys: List[bytes], x: bytes) -> List[bytes]:
Expand All @@ -285,11 +258,12 @@ async def certifying_eq(chan: SignerChannel, my_hostsigkey: bytes, hostverkeys:
while(True):
i, ty, msg = await chan.receive()
if ty == "SIG":
is_valid = schnorr_verify(x, hostverkeys[i], msg)
# TODO: We're just slicing into a hostverkey to get a 32 byte BIP 340
# pubkey. This makes signatures sort of malleable. Is this ok?
is_valid = schnorr_verify(x, hostverkeys[i][1:33], msg)
Comment on lines +261 to +263
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm rather convinced that it's okay. Malleability of public keys is not an issue for signatures. I remember that we discussed this a lot for BIP340 (where we even mention that dropping the first byte is an option, to reuse BIP32). Even if you look at this as a modified signature scheme that takes 33-bytes keys as input, this scheme is still SUF-CMA.

We need to be careful about malleability if we start to use the shortened pubkeys in other places, e.g., when we hash them. But your draft PR doesn't even do this.

But yeah, I agree it's not exactly elegant. We could also use x-only ECDH (bitcoin-core/secp256k1#1198), and this will be cleaner and faster. Or in principle also EllSwift, but that's a bit odd here because it doubles the key sizes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The downside of x-only ECDH is that it increases the complexity of the spec. Another option is ECDH with lift_x(xonly_pubkey), but that requires negating the seckey when seckey*G has an odd y coordinate.

if sigs[i] == b'' and is_valid:
sigs[i] = msg
elif not is_valid:
print("sig not valid for x", x)
# The signer `hpk` is either malicious or an honest signer
# whose input is not equal to `x`. This means that there is
# some malicious signer or that some messages have been
Expand All @@ -309,10 +283,6 @@ async def certifying_eq(chan: SignerChannel, my_hostsigkey: bytes, hostverkeys:
return cert

async def recpedpop_coordinate(chans: CoordinatorChannels, t: int, n: int) -> None:
enckeys = []
for i in range(n):
enckeys += [await chans.receive_from(i)]
chans.send_all(enckeys)
vss_commitments_ext = []
all_enc_shares_sum = [0]*n
for i in range(n):
Expand All @@ -332,13 +302,12 @@ async def recpedpop_coordinate(chans: CoordinatorChannels, t: int, n: int) -> No
# Recovery requires the seed and the public transcript
def recpedpop_recover(seed: bytes, transcript: Any) -> Union[Tuple[DKGOutput, Setup], bool]:
_, my_hostverkey = recpedpop_hostpubkey(seed)
setup, enckeys, vss_commitments_sum, all_enc_shares_sum, cert = transcript
setup, vss_commitments_sum, all_enc_shares_sum, cert = transcript
hostverkeys, _, _ = setup
if not my_hostverkey in hostverkeys:
return False

state1, _ = recpedpop_round1(seed, setup)
state2, _, _ = recpedpop_round2(seed, state1, enckeys)
state2, _, _ = recpedpop_round2(seed, setup)

eta, (shares_sum, shared_pubkey, signer_pubkeys) = recpedpop_pre_finalize(seed, state2, vss_commitments_sum, all_enc_shares_sum)
if not verify_cert(hostverkeys, eta, cert):
Expand Down
18 changes: 9 additions & 9 deletions reference/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ def simulate_simplpedpop(seeds, t):
dkg_outputs += [simplpedpop_pre_finalize(round1_outputs[i][0], vss_commitments_sum, shares_sum)]
return dkg_outputs

def encpedpop_round1(seed: bytes) -> Tuple[bytes, bytes]:
my_deckey = kdf(seed, "deckey")
my_enckey = pubkey_gen_plain(my_deckey)
return my_deckey, my_enckey

def simulate_encpedpop(seeds, t):
n = len(seeds)
round1_outputs = []
Expand All @@ -39,7 +44,8 @@ def simulate_encpedpop(seeds, t):

enckeys = [out[1] for out in round1_outputs]
for i in range(n):
round2_outputs += [encpedpop_round2(seeds[i], round1_outputs[i][0], t, n, enckeys)]
my_deckey = round1_outputs[i][0]
round2_outputs += [encpedpop_round2(seeds[i], t, n, my_deckey, enckeys, i)]

vss_commitments_ext = [out[1] for out in round2_outputs]
vss_commitments_sum = vss_sum_commitments(vss_commitments_ext, t)
Expand All @@ -56,17 +62,11 @@ def simulate_recpedpop(seeds, t):
hostkeys += [recpedpop_hostpubkey(seeds[i])]

hostverkeys = [hostkey[1] for hostkey in hostkeys]
setup_id = recpedpop_setup_id(hostverkeys, t, b'')
setup, _ = recpedpop_setup_id(hostverkeys, t, b'')

round1_outputs = []
for i in range(n):
round1_outputs += [recpedpop_round1(seeds[i], setup_id[0])]

state1s = [out[0] for out in round1_outputs]
enckeys = [out[1] for out in round1_outputs]
round2_outputs = []
for i in range(n):
round2_outputs += [recpedpop_round2(seeds[i], state1s[i], enckeys)]
round2_outputs += [recpedpop_round2(seeds[i], setup)]

state2s = [out[0] for out in round2_outputs]
vss_commitments_ext = [out[1] for out in round2_outputs]
Expand Down