Skip to content
Open
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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Changelog

## [0.2.0] — Threshold metadata enforcement

**Highlights**

- `KeygenOutput` now serializes a `ThresholdParameters` struct so thresholds are
persisted with every key/share. APIs such as `refresh` consume this metadata
directly, preventing callers from silently changing the threshold across
subprotocols.
- `refresh` signature changed to `refresh(&KeygenOutput, &[Participant], me, rng)`.
- `docs/threshold_policies.md` updated to describe the new persistence rules
and high-visibility warnings were added to `README.md`.

**Migration steps**

1. **Update stored key material:** If you serialize `KeygenOutput`, add support
for the new `threshold_params` field (or re-run keygen with 0.2.0 to regenerate
keys that include it).
2. **Adjust refresh calls:** Replace previous `refresh` invocations with the new
signature by passing the entire `KeygenOutput` instead of separate share
and threshold arguments.
3. **Reshare inputs:** When calling `reshare`, feed in the `threshold_params`
extracted from existing keys to satisfy the enforced invariants.

All other APIs retain their semantics, but will now error if provided threshold
values differ from the persisted metadata.

---

## [0.1.0] — Initial release

*Original audited release prior to explicit threshold enforcement.*
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "threshold-signatures"
description = "Threshold Signatures"
repository = "https://github.com/near/threshold-signatures"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "MIT"

Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,29 @@ provide deterministic secrets to apps running inside a TEE. For more details,
see the
[CKD docs](docs/confidential_key_derivation/confidential_key_derivation.md).

## ⚠ Threshold Policies & Security Assumptions

Threshold parameters are security-critical and **must not** be changed across
subprotocols (keygen, presign, signing, refresh, resharing, etc.) unless a
scheme explicitly documents the exception. See
[`docs/threshold_policies.md`](docs/threshold_policies.md) for the full table of
constraints. Highlights:

* Refresh/reshare must reuse the exact threshold metadata embedded in the
original key material; lowering the threshold is treated as a bug.
* DKG inherits the asynchronous broadcast bound `f <= floor(N/3)` and requires
`t = f + 1` for reconstruction.
* OT-based ECDSA hardcodes `t = f + 1` and requires at least `t` live parties at
presign/sign time.
* Robust ECDSA uses `threshold = f` and needs `2f + 1` active parties to sign.
* Every `KeygenOutput` now includes `threshold_params`; APIs like `refresh` and
`reshare` read that metadata directly so callers can't accidentally change the
threshold between protocol stages.

All subprotocols currently follow the "everything is invariant" policy. Future
exceptions (for schemes that genuinely support parameter drift) will be listed
in the same document along with their rationale.

## Code organization

The repository provides implementations for ECDSA, EdDSA and CKD. Each signature
Expand Down
95 changes: 95 additions & 0 deletions docs/threshold_policies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Threshold Policies & Security Assumptions

This document defines the **non-negotiable invariants** between participant count `N`, tolerated malicious parties `f`, reconstruction threshold `t`, and liveness requirements. All protocols in this repository **must** enforce these rules unless a future `SchemeRules` table explicitly allows an exception.

## Global Principle

**The tuple `(N, f, t)` chosen at key generation is fixed for all subprotocols**
(keygen → presign/offline → signing → refresh → resharing).
Any deviation **must be rejected** unless explicitly whitelisted by `SchemeRules`.

_If a different threshold is needed, run a new DKG._

## Why invariance is mandatory

Changing thresholds at any stage can silently weaken security (e.g., reducing signing quorum, increasing adversarial influence, or invalidating proofs from DKG). Recent industry incidents show this is a practical risk. The library therefore enforces all parameters unless explicitly allowed.

## ThresholdPolicy & SchemeRules

- **ThresholdPolicy:**
Defines which parameters (`N`, `f`, `t`, participant set) must remain invariant.
Default: **full invariance across all subprotocols**.

- **SchemeRules:**
Future, scheme-specific exceptions.
No exceptions exist today; all protocols must treat thresholds as fixed.

## Persistence Requirement

All keys, shares, or transcripts that leave the protocol must embed `(N, f, t)` so future API calls can enforce invariants even if the caller did not store them.

In practice, every `KeygenOutput` now carries a serialized `ThresholdParameters`
struct. Public APIs (`refresh`, `reshare`, etc.) consume that struct directly so
callers cannot "forget" the previous threshold or accidentally invent a new
one during refresh/resharing.

---

# Scheme-Specific Constraints

## Distributed Key Generation (DKG)

- **Fault tolerance:**
`f <= floor(N / 3)` due to asynchronous reliable broadcast.
- **Threshold:**
`t = f + 1` (design choice; distinct from `N - f`).
- **Required checks:**
- Reject if `f >= N / 3`.
- Reject if `t != f + 1`.
- **Invariance:**
DKG → refresh → resharing all reuse the exact `(N, f, t)`.

## OT-based ECDSA

- **Definitions:**
`f = max_malicious_parties`, `t = f + 1`.
- **Liveness:**
Signing/presigning requires `N_live >= t`.
- **Threshold consistency:**
Any mismatch between supplied `(f, t)` must be rejected.
- **Invariance:**
Keygen, triple generation, presign, sign, refresh all share the same `(f, t)`.

## Robust ECDSA (secret-sharing-based)

- **Parameterization:**
Scheme is defined by `f`; effective threshold derived from `f`.
- **Liveness:**
Signing requires `N_live >= 2f + 1`.
If this fails, resharing is required before signing.
- **Invariance:**
`f` remains constant across keygen, presign (if present), signing, refresh.
(Future SchemeRules may allow `N_live` to exceed offline `N`, but not implemented yet.)

---

# Refresh & Resharing Safety

- Never change thresholds during refresh/resharing. Doing so breaks the assumptions of all supported schemes.
- To adopt a new threshold: **Perform a new DKG**, archive the old key, and migrate intentionally.
- Threshold metadata must always be persisted together with key/share identifiers.

---

# Consequences of Misconfiguration

| Misconfiguration | Failure Mode |
| ------------------------------------------- | --------------------------------------------------------------------- |
| `f >= N/3` in DKG | Broadcast assumptions break; safety and liveness lost. |
| `t != f + 1` in DKG or OT-ECDSA | Scheme assumptions violated; security/liveness not guaranteed. |
| Threshold lowered during refresh | Old shares become over-powerful; confidentiality/unforgeability fail. |
| Robust ECDSA signing with `N_live < 2f + 1` | Protocol aborts or risks leakage/invalid signatures. |

---

If a scheme allows different parameters between subprotocols, the corresponding `SchemeRules` entry **must** document the exact conditions.
15 changes: 11 additions & 4 deletions src/dkg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::participants::{Participant, ParticipantList, ParticipantMap};
use crate::protocol::{
echo_broadcast::do_broadcast, helpers::recv_from_others, internal::SharedChannel,
};
use crate::KeygenOutput;
use crate::{KeygenOutput, ThresholdParameters};

use frost_core::keys::{
CoefficientCommitment, SecretShare, SigningShare, VerifiableSecretSharingCommitment,
Expand Down Expand Up @@ -509,6 +509,7 @@ async fn do_keyshare<C: Ciphersuite>(
Ok(KeygenOutput {
private_share: SigningShare::new(my_signing_share),
public_key: verifying_key,
threshold_params: ThresholdParameters::new(threshold),
})
}

Expand Down Expand Up @@ -610,7 +611,7 @@ pub fn reshare_assertions<C: Ciphersuite>(
me: Participant,
threshold: usize,
old_signing_key: Option<SigningShare<C>>,
old_threshold: usize,
old_params: ThresholdParameters,
old_participants: &[Participant],
) -> Result<(ParticipantList, ParticipantList), InitializationError> {
if participants.len() < 2 {
Expand Down Expand Up @@ -638,6 +639,7 @@ pub fn reshare_assertions<C: Ciphersuite>(
let old_participants =
ParticipantList::new(old_participants).ok_or(InitializationError::DuplicateParticipants)?;

let old_threshold = old_params.signing_threshold();
if old_participants.intersection(&participants).len() < old_threshold {
return Err(InitializationError::NotEnoughParticipantsForThreshold {
threshold: old_threshold,
Expand Down Expand Up @@ -709,8 +711,11 @@ pub mod test {

let pub_key = result0[0].1.public_key.to_element();

let result1 = run_refresh(participants, &result0, threshold);
let result1 = run_refresh(participants, &result0);
assert_public_key_invariant(&result1);
assert!(result1
.iter()
.all(|(_, key)| key.threshold_params.signing_threshold() == threshold));

let participants = result1.iter().map(|p| p.0).collect::<Vec<_>>();
let shares = result1
Expand Down Expand Up @@ -745,11 +750,13 @@ pub mod test {
participants,
&pub_key,
&result0,
threshold0,
threshold1,
&new_participant,
);
assert_public_key_invariant(&result1);
assert!(result1
.iter()
.all(|(_, key)| key.threshold_params.signing_threshold() == threshold1));

let participants = result1.iter().map(|p| p.0).collect::<Vec<_>>();
let shares = result1
Expand Down
4 changes: 3 additions & 1 deletion src/ecdsa/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ mod test {
generate_participants, generate_participants_with_random_ids, random_32_bytes,
MockCryptoRng,
},
ThresholdParameters,
};

use elliptic_curve::ops::{Invert, LinearCombination, Reduce};
Expand Down Expand Up @@ -251,6 +252,7 @@ mod test {
let keygen_output = KeygenOutput {
private_share: SigningShare::<C>::new(Scalar::ONE),
public_key: FrostVerifyingKey::<C>::from(signing_key),
threshold_params: ThresholdParameters::new(2),
};

// When
Expand All @@ -260,7 +262,7 @@ mod test {
// Then
assert_eq!(
serialized_keygen_output,
"{\"private_share\":\"0000000000000000000000000000000000000000000000000000000000000001\",\"public_key\":\"0351177dde89242d9121d787a681bd2a0bd6013428a6b83e684a253815db96d8b3\"}"
"{\"private_share\":\"0000000000000000000000000000000000000000000000000000000000000001\",\"public_key\":\"0351177dde89242d9121d787a681bd2a0bd6013428a6b83e684a253815db96d8b3\",\"threshold_params\":{\"signing_threshold\":2}}"
);
}

Expand Down
2 changes: 2 additions & 0 deletions src/ecdsa/ot_based_ecdsa/presign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ mod test {
use crate::{
ecdsa::{ot_based_ecdsa::triples::test::deal, KeygenOutput, Polynomial, ProjectivePoint},
test_utils::{generate_participants, run_protocol, GenProtocol},
ThresholdParameters,
};
use frost_secp256k1::{
keys::{PublicKeyPackage, SigningShare},
Expand Down Expand Up @@ -228,6 +229,7 @@ mod test {
let keygen_out = KeygenOutput {
private_share: SigningShare::new(private_share),
public_key: *public_key_package.verifying_key(),
threshold_params: ThresholdParameters::new(original_threshold),
};

let protocol = presign(
Expand Down
4 changes: 1 addition & 3 deletions src/ecdsa/ot_based_ecdsa/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ fn test_refresh() {
let keys = run_keygen(&participants, threshold);
assert_public_key_invariant(&keys);
// run refresh on these
let key_packages = run_refresh(&participants, &keys, threshold);
let key_packages = run_refresh(&participants, &keys);
let public_key = key_packages[0].1.public_key;
assert_public_key_invariant(&key_packages);
let (pub0, shares0) = deal(&mut OsRng, &participants, threshold).unwrap();
Expand Down Expand Up @@ -208,7 +208,6 @@ fn test_reshare_sign_more_participants() -> Result<(), Box<dyn Error>> {
&participants,
&pub_key,
&result0,
threshold,
new_threshold,
&new_participant,
);
Expand Down Expand Up @@ -246,7 +245,6 @@ fn test_reshare_sign_less_participants() -> Result<(), Box<dyn Error>> {
&participants,
&pub_key,
&result0,
threshold,
new_threshold,
&new_participant,
);
Expand Down
2 changes: 2 additions & 0 deletions src/ecdsa/robust_ecdsa/presign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ mod test {

use crate::ecdsa::KeygenOutput;
use crate::test_utils::{generate_participants, run_protocol, GenProtocol};
use crate::ThresholdParameters;
use frost_secp256k1::keys::PublicKeyPackage;
use frost_secp256k1::VerifyingKey;

Expand All @@ -384,6 +385,7 @@ mod test {
let keygen_out = KeygenOutput {
private_share: SigningShare::new(private_share.0),
public_key: *public_key_package.verifying_key(),
threshold_params: ThresholdParameters::new(max_malicious + 1),
};

let protocol = presign(
Expand Down
4 changes: 1 addition & 3 deletions src/ecdsa/robust_ecdsa/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ fn test_refresh() -> Result<(), Box<dyn Error>> {
let keys = run_keygen(&participants, threshold);
assert_public_key_invariant(&keys);
// run refresh on these
let key_packages = run_refresh(&participants, &keys, threshold);
let key_packages = run_refresh(&participants, &keys);
let public_key = key_packages[0].1.public_key;
assert_public_key_invariant(&key_packages);
let presign_result = run_presign(key_packages, max_malicious);
Expand Down Expand Up @@ -190,7 +190,6 @@ fn test_reshare_sign_more_participants() -> Result<(), Box<dyn Error>> {
&participants,
&pub_key,
&result0,
threshold,
new_threshold,
&new_participant,
);
Expand Down Expand Up @@ -226,7 +225,6 @@ fn test_reshare_sign_less_participants() -> Result<(), Box<dyn Error>> {
&participants,
&pub_key,
&result0,
threshold,
new_threshold,
&new_participant,
);
Expand Down
5 changes: 1 addition & 4 deletions src/eddsa/sign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ mod test {
.is_ok());

// // test refresh
let key_packages1 = run_refresh(&participants, &key_packages, threshold);
let key_packages1 = run_refresh(&participants, &key_packages);
assert_public_key_invariant(&key_packages1);
let msg = "hello_near_2";
let msg_hash = hash(&msg).unwrap();
Expand Down Expand Up @@ -411,7 +411,6 @@ mod test {
&participants,
&pub_key,
&key_packages1,
threshold,
new_threshold,
&new_participant,
);
Expand Down Expand Up @@ -454,7 +453,6 @@ mod test {
&participants,
&pub_key,
&result0,
threshold,
new_threshold,
&new_participant,
);
Expand Down Expand Up @@ -519,7 +517,6 @@ mod test {
&participants,
&pub_key,
&result0,
threshold,
new_threshold,
&new_participant,
);
Expand Down
Loading