Skip to content

Commit ad69a7c

Browse files
committed
python: blame by index, and fix test vector bug
1 parent 289286c commit ad69a7c

9 files changed

Lines changed: 111 additions & 168 deletions

File tree

bip-0445.md

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ Aborts are identifiable for an honest party if the following conditions hold in
178178
- Nonce aggregation is performed honestly (e.g., because the honest signer performs nonce aggregation on its own or because the coordinator is trusted).
179179
- The partial signatures received from all signers are verified using the algorithm *PartialSigVerify*.
180180

181-
If these conditions hold and an honest party (signer or coordinator) runs an algorithm that fails due to invalid protocol contributions from malicious signers, then the algorithm run by the honest party will output the participant identifier of exactly one malicious signer.
181+
If these conditions hold and an honest party (signer or coordinator) runs an algorithm that fails due to invalid protocol contributions from malicious signers, then the algorithm run by the honest party will output the index (within the input list) of exactly one malicious signer.
182182
Additionally, if the honest parties agree on the contributions sent by all signers in the signing session, all the honest parties who run the aborting algorithm will identify the same malicious signer.
183183

184184
#### Further Remarks
@@ -439,15 +439,14 @@ Algorithm *NonceGen(secshare, pubshare, thresh_pk, m, extra_in)*:
439439

440440
### Nonce Aggregation
441441

442-
Algorithm *NonceAgg(pubnonce<sub>1..u</sub>, id<sub>1..u</sub>)*:
442+
Algorithm *NonceAgg(pubnonce<sub>1..u</sub>)*:
443443

444444
- Inputs:
445445
- The number *u* of signing participants: an integer with *t ≤ u ≤ n*
446446
- The list of participant public nonces *pubnonce<sub>1..u</sub>**u* 66-byte array, each an output of *NonceGen*
447-
- The list of participant identifiers *id<sub>1..u</sub>*: *u* integers, each with *0 ≤ id<sub>i</sub> ≤ n-1*
448447
- For *j = 1 .. 2*:
449448
- For *i = 1 .. u*:
450-
- Let *R<sub>i,j</sub> = cpoint(pubnonce<sub>i</sub>[(j-1)\*33:j\*33])*; fail if that fails and blame signer *id<sub>i</sub>* for invalid *pubnonce*
449+
- Let *R<sub>i,j</sub> = cpoint(pubnonce<sub>i</sub>[(j-1)\*33:j\*33])*; fail if that fails and blame signer at index *i* for invalid *pubnonce*
451450
- Let *R<sub>j</sub> = R<sub>1,j</sub> + R<sub>2,j</sub> + ... + R<sub>u,j</sub>*
452451
- Return *aggnonce = cbytes_ext(R<sub>1</sub>) || cbytes_ext(R<sub>2</sub>)*
453452

@@ -534,8 +533,9 @@ Algorithm *PartialSigVerify(psig, pubnonce<sub>1..u</sub>, signers_ctx, tweak<su
534533
- The list of tweak modes *is_xonly_t<sub>1..v</sub>* : *v* booleans
535534
- The message *m*: a byte array[^max-msg-len]
536535
- The index *i* of the signer in the list of public nonces where *0 < i ≤ u*
536+
- ValidateSignersCtx(signers_ctx); fail if that fails
537537
- Let *(_, _, u, id<sub>1..u</sub>, pubshare<sub>1..u</sub>, _) = signers_ctx*
538-
- Let *aggnonce = NonceAgg(pubnonce<sub>1..u</sub>, id<sub>1..u</sub>)*; fail if that fails
538+
- Let *aggnonce = NonceAgg(pubnonce<sub>1..u</sub>)*; fail if that fails
539539
- Let *session_ctx = (signers_ctx, aggnonce, v, tweak<sub>1..v</sub>, is_xonly_t<sub>1..v</sub>, m)*
540540
- Run *PartialSigVerifyInternal(psig, id<sub>i</sub>, pubnonce<sub>i</sub>, pubshare<sub>i</sub>, session_ctx)*
541541
- Return success iff no failure occurred before reaching this point.
@@ -560,16 +560,15 @@ Internal Algorithm *PartialSigVerifyInternal(psig, my_id, pubnonce, pubshare, se
560560

561561
### Partial Signature Aggregation
562562

563-
Algorithm *PartialSigAgg(psig<sub>1..u</sub>, id<sub>1..u</sub>, session_ctx)*:
563+
Algorithm *PartialSigAgg(psig<sub>1..u</sub>, session_ctx)*:
564564

565565
- Inputs:
566566
- The number *u* of signatures with *t ≤ u ≤ n*
567567
- The list of partial signatures *psig<sub>1..u</sub>**u* 32-byte arrays, each an output of *Sign*
568-
- The list of participant identifiers *id<sub>1..u</sub>*: *u* distinct integers, each with *0 ≤ id<sub>i</sub> ≤ n-1*
569568
- The *session_ctx*: a [Session Context](#session-context) data structure
570569
- Let *(Q, _, tacc, _, _, _, R, e) = GetSessionValues(session_ctx)*; fail if that fails
571570
- For *i = 1 .. u*:
572-
- Let *s<sub>i</sub> = scalar_from_bytes_nonzero_checked(psig<sub>i</sub>)*; fail if that fails and blame signer *id<sub>i</sub>* for invalid partial signature.
571+
- Let *s<sub>i</sub> = scalar_from_bytes_nonzero_checked(psig<sub>i</sub>)*; fail if that fails and blame signer at index *i* for invalid partial signature.
573572
- Let *g = Scalar(1)* if *has_even_y(Q)*, otherwise let *g = Scalar(-1)*
574573
- Let *s = s<sub>1</sub> + ... + s<sub>u</sub> + e &middot; g &middot; tacc &ensp;(mod ord)*
575574
- Return *sig = xbytes(R) || scalar_to_bytes(s)*
@@ -639,12 +638,10 @@ Algorithm *DeterministicSign(secshare, my_id, aggothernonce, signers_ctx, tweak<
639638
- Let *my_pubshare = cbytes(d &middot; G)*
640639
- Fail if *my_pubshare* is not present in *pubshare<sub>1..u</sub>*
641640
- Let *secnonce = scalar_to_bytes(k<sub>1</sub>) || scalar_to_bytes(k<sub>2</sub>)*
642-
- Let *aggnonce = NonceAgg((pubnonce, aggothernonce), (my_id, COORDINATOR_ID))*[^coordinator-id-sentinel]; fail if that fails and blame coordinator for invalid *aggothernonce*.
641+
- Let *aggnonce = NonceAgg((pubnonce, aggothernonce))*; fail if that fails and blame coordinator for invalid *aggothernonce*.
643642
- Let *session_ctx = (signers_ctx, aggnonce, v, tweak<sub>1..v</sub>, is_xonly_t<sub>1..v</sub>, m)*
644643
- Return (pubnonce, Sign(secnonce, secshare, my_id, session_ctx))
645644

646-
[^coordinator-id-sentinel]: *COORDINATOR_ID* is a sentinel value (not an actual participant identifier) used to track the source of *aggothernonce* for error attribution. If *NonceAgg* fails, the coordinator is blamed for providing an invalid *aggothernonce*. In the reference implementation, *COORDINATOR_ID* is represented as *None*.
647-
648645
### Tweaking Definition
649646

650647
Two modes of tweaking the threshold public key are supported. They correspond to the following algorithms:
@@ -785,6 +782,7 @@ This document proposes a standard for the FROST threshold signature scheme that
785782

786783
## Changelog
787784

785+
- *0.4.1* (2026-03-03): Assign blame to signer index (of the input list) instead of their identifier value
788786
- *0.4.0* (2026-01-30): Number 445 was assigned to this BIP.
789787
- *0.3.6* (2026-01-28): Add MIT license file for reference code and other auxiliary files.
790788
- *0.3.5* (2026-01-25): Update secp256k1lab to latest version, remove stub file, and fix formatting in the BIP text.

bip-0445/python/example.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ async def coordinator(
160160
pubnonces.append(pubnonce)
161161

162162
# Aggregate nonces
163-
aggnonce = nonce_agg(pubnonces, signer_ids)
163+
aggnonce = nonce_agg(pubnonces)
164164
chans.send_all(aggnonce)
165165

166166
# Round 2: Collect partial signatures
@@ -174,7 +174,7 @@ async def coordinator(
174174
psigs.append(psig)
175175

176176
# Aggregate partial signatures
177-
final_sig = partial_sig_agg(psigs, signer_ids, session_ctx)
177+
final_sig = partial_sig_agg(psigs, session_ctx)
178178
chans.send_all(final_sig)
179179

180180
return final_sig

bip-0445/python/frost_ref/signing.py

Lines changed: 19 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# be used in production environments. The code is vulnerable to timing attacks,
99
# for example.
1010

11-
from typing import List, Optional, Tuple, NewType, NamedTuple, Sequence, Literal
11+
from typing import List, Optional, Tuple, NewType, NamedTuple, Literal
1212
import secrets
1313

1414
from secp256k1lab.secp256k1 import G, GE, Scalar
@@ -37,10 +37,9 @@
3737
# values. Actual implementations should not crash when receiving invalid
3838
# contributions. Instead, they should hold the offending party accountable.
3939
class InvalidContributionError(Exception):
40-
def __init__(self, signer_id: Optional[int], contrib: ContribKind) -> None:
41-
# participant identifier of the signer who sent the invalid value
42-
self.id = signer_id
43-
# contrib is one of "pubkey", "pubnonce", "aggnonce", or "psig".
40+
def __init__(self, signer_index: Optional[int], contrib: ContribKind) -> None:
41+
# index of the signer who sent the invalid value, or None for coordinator
42+
self.signer_index = signer_index
4443
self.contrib = contrib
4544

4645

@@ -60,11 +59,11 @@ def derive_interpolating_value(ids: List[int], my_id: int) -> Scalar:
6059

6160
def derive_thresh_pubkey(ids: List[int], pubshares: List[PlainPk]) -> PlainPk:
6261
Q = GE()
63-
for my_id, pubshare in zip(ids, pubshares):
62+
for idx, (my_id, pubshare) in enumerate(zip(ids, pubshares)):
6463
try:
6564
X_i = GE.from_bytes_compressed(pubshare)
6665
except ValueError:
67-
raise InvalidContributionError(my_id, "pubshare")
66+
raise InvalidContributionError(idx, "pubshare")
6867
lam_i = derive_interpolating_value(ids, my_id)
6968
Q = Q + lam_i * X_i
7069
# Q is not the point at infinity except with negligible probability.
@@ -88,13 +87,13 @@ def validate_signers_ctx(signers_ctx: SignersContext) -> None:
8887
raise ValueError("The number of signers must be between t and n.")
8988
if len(pubshares) != len(ids):
9089
raise ValueError("The pubshares and ids arrays must have the same length.")
91-
for i, pubshare in zip(ids, pubshares):
90+
for idx, (i, pubshare) in enumerate(zip(ids, pubshares)):
9291
if not 0 <= i <= n - 1:
9392
raise ValueError(f"The participant identifier {i} is out of range.")
9493
try:
9594
_ = GE.from_bytes_compressed(pubshare)
9695
except ValueError:
97-
raise InvalidContributionError(i, "pubshare")
96+
raise InvalidContributionError(idx, "pubshare")
9897
if len(set(ids)) != len(ids):
9998
raise ValueError("The participant identifier list contains duplicate elements.")
10099
if derive_thresh_pubkey(ids, pubshares) != thresh_pk:
@@ -235,20 +234,15 @@ def nonce_gen(
235234
# in each function that takes `ids` as argument?
236235

237236

238-
# `ids` is typed as Sequence[Optional[int]] so that callers can pass either
239-
# List[int] or List[Optional[int]] without triggering mypy invariance errors.
240-
# Sequence is read-only and covariant.
241-
def nonce_agg(pubnonces: List[bytes], ids: Sequence[Optional[int]]) -> bytes:
242-
if len(pubnonces) != len(ids):
243-
raise ValueError("The pubnonces and ids arrays must have the same length.")
237+
def nonce_agg(pubnonces: List[bytes]) -> bytes:
244238
aggnonce = b""
245239
for j in (1, 2):
246240
R_j = GE()
247-
for my_id, pubnonce in zip(ids, pubnonces):
241+
for idx, pubnonce in enumerate(pubnonces):
248242
try:
249243
R_ij = GE.from_bytes_compressed(pubnonce[(j - 1) * 33 : j * 33])
250244
except ValueError:
251-
raise InvalidContributionError(my_id, "pubnonce")
245+
raise InvalidContributionError(idx, "pubnonce")
252246
R_j = R_j + R_ij
253247
aggnonce += R_j.to_bytes_compressed_with_infinity()
254248
return aggnonce
@@ -375,9 +369,6 @@ def det_nonce_hash(
375369
return tagged_hash("FROST/deterministic/nonce", buf)
376370

377371

378-
COORDINATOR_ID = None
379-
380-
381372
def deterministic_sign(
382373
secshare: bytes,
383374
my_id: int,
@@ -414,11 +405,10 @@ def deterministic_sign(
414405
pubnonce = R1_partial.to_bytes_compressed() + R2_partial.to_bytes_compressed()
415406
secnonce = bytearray(k_1.to_bytes() + k_2.to_bytes())
416407
try:
417-
aggnonce = nonce_agg([pubnonce, aggothernonce], [my_id, COORDINATOR_ID])
408+
aggnonce = nonce_agg([pubnonce, aggothernonce])
418409
except Exception:
419-
# Since `pubnonce` can never be invalid, blame coordinator's pubnonce.
420-
# REVIEW: should we introduce an unknown participant or coordinator error?
421-
raise InvalidContributionError(COORDINATOR_ID, "aggothernonce")
410+
# pubnonce is always valid, so any failure is due to aggothernonce.
411+
raise InvalidContributionError(None, "aggothernonce")
422412
session_ctx = SessionContext(aggnonce, signers_ctx, tweaks, is_xonly, msg)
423413
psig = sign(secnonce, secshare, my_id, session_ctx)
424414
return (pubnonce, psig)
@@ -439,7 +429,7 @@ def partial_sig_verify(
439429
raise ValueError("The pubnonces and ids arrays must have the same length.")
440430
if len(tweaks) != len(is_xonly):
441431
raise ValueError("The tweaks and is_xonly arrays must have the same length.")
442-
aggnonce = nonce_agg(pubnonces, ids)
432+
aggnonce = nonce_agg(pubnonces)
443433
session_ctx = SessionContext(aggnonce, signers_ctx, tweaks, is_xonly, msg)
444434
return partial_sig_verify_internal(
445435
psig, ids[i], pubnonces[i], pubshares[i], session_ctx
@@ -480,19 +470,16 @@ def partial_sig_verify_internal(
480470
return s * G == Re_s + (e * a * g_) * P
481471

482472

483-
def partial_sig_agg(
484-
psigs: List[bytes], ids: List[int], session_ctx: SessionContext
485-
) -> bytes:
486-
assert COORDINATOR_ID not in ids
473+
def partial_sig_agg(psigs: List[bytes], session_ctx: SessionContext) -> bytes:
474+
(Q, _, tacc, ids, _, _, R, e) = get_session_values(session_ctx)
487475
if len(psigs) != len(ids):
488476
raise ValueError("The psigs and ids arrays must have the same length.")
489-
(Q, _, tacc, _, _, _, R, e) = get_session_values(session_ctx)
490477
s = Scalar(0)
491-
for my_id, psig in zip(ids, psigs):
478+
for idx, psig in enumerate(psigs):
492479
try:
493480
s_i = Scalar.from_bytes_checked(psig)
494481
except ValueError:
495-
raise InvalidContributionError(my_id, "psig")
482+
raise InvalidContributionError(idx, "psig")
496483
s = s + s_i
497484
g = Scalar(1) if Q.has_even_y() else Scalar(-1)
498485
s = s + e * g * tacc

0 commit comments

Comments
 (0)