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

feat: DKG verification pre-key-rotation #1301

Draft
wants to merge 18 commits into
base: feat/require-all-signatures-for-rotate-keys
Choose a base branch
from

Conversation

cylewitruk
Copy link
Member

@cylewitruk cylewitruk commented Feb 4, 2025

Description

Closes: #1300 (Alternative 3)

⚠️ This PR targets #1285 which in turn targets #1287 and needs to merge prior to either of those.

I'm still actively working on this but feel free to comment

Changes

Testing Information

Checklist:

  • I have performed a self-review of my code
  • My changes generate no new warnings
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

@cylewitruk cylewitruk added sbtc signer binary The sBTC Bootstrap Signer. signer coordination The actions executed by the signer coordinator. key rotation The functionality to rotate a private key for a signer in sBTC-v1. breaking-protocol Breaking protocol changes labels Feb 4, 2025
@cylewitruk cylewitruk added this to the sBTC: Deposits milestone Feb 4, 2025
@cylewitruk cylewitruk self-assigned this Feb 4, 2025
@cylewitruk cylewitruk changed the title Feat/mock signing feat: DKG verification pre-key-rotation Feb 4, 2025
Copy link
Collaborator

@djordon djordon left a comment

Choose a reason for hiding this comment

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

Hey, nice work. Have have some high level comments:

  1. Why add a new table? Why not just a simple is_verified boolean column that is nullable, where NULL means that verification has not been attempted, false means that verification has been attempted and failed and true means that it has been successfully verified. It's much simplier, it's intuitive (to me at least), and gives us the functionality that we need.
  2. Why support revocation of the keys? Once they're verified they'll stay verified. I would think that verification is a one way street, it goes from unknown to either passing or failing verification. Than again, maybe this is a language thing.
  3. I would add column comments in the database instead of a description field, with COMMENT ON COLUMN blah IS 'some comment'. If we do not want to add column comments, then we can just use regular code comments.
  4. Why not use the bitcoin::Transaction::verify_with_flags function in verify_signature? It's much simpler and does the same thing.
  5. I feel like the block hash and block height columns in the dkg_shares should be linked to the started at height, not the ended/verified at. In the future, we should support DKG ending on a block that is different from the block that it started at. If we do that, it's possible for signers to have ending blocks (so different verified at blocks). But, maybe this is really just something to do with naming rather than meaning?

/// construct a [`Transaction`] with a single input and output.
pub fn new(signer_public_key: XOnlyPublicKey) -> Self {
let utxo = SignerUtxo {
outpoint: OutPoint::new(Txid::all_zeros(), 0),
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is fine, but OutPoint::null() is even simpler.

input: vec![utxo.as_tx_input(&DUMMY_SIGNATURE)],
output: vec![TxOut {
value: Amount::from_sat(Self::AMOUNT),
script_pubkey: ScriptBuf::new_op_return([0x01, 0x02, 0x03]),
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is fine, but ScriptBuf::new_op_return([]) is even simpler.

Comment on lines +871 to +906

// Encode the transaction to bytes (needed by the bitcoinconsensus
// library).
let mut tx_bytes: Vec<u8> = Vec::new();
tx.consensus_encode(&mut tx_bytes)
.map_err(Error::BitcoinIo)?;

// Get the prevout for the signers' UTXO.
let prevout = self.utxo.as_tx_output();
let prevout_script_bytes = prevout.script_pubkey.as_script().as_bytes();

// Create the bitcoinconsensus UTXO object.
let prevout_utxo = bitcoinconsensus::Utxo {
script_pubkey: prevout_script_bytes.as_ptr(),
script_pubkey_len: prevout_script_bytes.len() as u32,
value: Self::AMOUNT as i64,
};

// We specify the flags to include all pre-taproot and taproot
// verifications explicitly.
// https://github.com/rust-bitcoin/rust-bitcoinconsensus/blob/master/src/lib.rs
let flags = bitcoinconsensus::VERIFY_ALL_PRE_TAPROOT | bitcoinconsensus::VERIFY_TAPROOT;

// Verify that the transaction updated with the provided signature can
// successfully spend the signers' UTXO. Note that the amount is not
// used in the verification process for taproot spends, only the
// signature.
bitcoinconsensus::verify_with_flags(
prevout_script_bytes,
Self::AMOUNT,
&tx_bytes,
Some(&[prevout_utxo]),
0,
flags,
)
.map_err(Error::bitcoin_consensus)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should simplify the above to

// We specify the flags to include all pre-taproot and taproot
// verifications explicitly.
// https://github.com/rust-bitcoin/rust-bitcoinconsensus/blob/d1a3554fbf031beb725883b932155f8316887769/src/lib.rs
let flags = bitcoinconsensus::VERIFY_ALL_PRE_TAPROOT | bitcoinconsensus::VERIFY_TAPROOT;
tx.verify_with_flags(|_| Some(self.utxo.as_tx_output()), flags)
    .map_err(Error::BitcoinConsensus);

or something similar.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Turns out that this doesn't work, as our unit test does not pass with this change. After some digging, we need a later version of bitcoinconsensus in order for the verification to work, and so we need the changes here.

Comment on lines +692 to +695
/// Convert a bitcoin consensus error to an `error::Error`
pub fn bitcoin_consensus(err: bitcoinconsensus::Error) -> Self {
Error::BitcoinConsensus(err)
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: this seems unnecessary, we can replace Error::bitcoin_consensus with Error::BitcoinConsensus.

Copy link
Member Author

Choose a reason for hiding this comment

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

pretty sure i tried that and ended up here, but will try it again

Copy link
Collaborator

@matteojug matteojug left a comment

Choose a reason for hiding this comment

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

General approach LGTM, sending some preliminary comments, but I'm also +1 Daniel points that could simplify some scaffolding in here. I would also add (at query level?) that you can set the verify (being successful or failed) only to pending DKG entries.

Comment on lines +1821 to +1826
let keypair = secp256k1::Keypair::from_secret_key(&secp, &secret_key);
let schnorr_sig = secp.sign_schnorr(&message, &keypair);
let taproot_sig = bitcoin::taproot::Signature {
signature: schnorr_sig,
sighash_type: TapSighashType::All,
};
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we also test with tap_tweak here? To be sure this is not failing because of an untweaked key

/// The rotate-key frost verification signing round failed for the aggregate
/// key.
#[error("rotate-key frost verification signing failed for aggregate key: {0}")]
DkgVerificationFailed(Box<PublicKeyXOnly>),
Copy link
Collaborator

Choose a reason for hiding this comment

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

how does this differ from DkgVerificationNotSuccessful?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I was about to ask the same :)

Copy link
Member Author

Choose a reason for hiding this comment

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

I believe I meant this as "The DKG verification is not successful (yet?)" (i.e. when validating a key-rotation contract call), whereas DkgVerificationFailed is hard fail

@@ -703,6 +714,78 @@ where
Ok(())
}

/// Performs verification of of the DKG process by running a FROST signing
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
/// Performs verification of of the DKG process by running a FROST signing
/// Performs verification of the DKG process by running a FROST signing

Comment on lines +732 to +734
/// [`UnsignedMockTransaction`] using the new aggregate key using the FROST
/// coordinator, which requires 100% signing participation vs. FIRE which
/// only uses {threshold} signers.
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: the last part is a repetition of the paragraph above

.inspect_err(|error| {
tracing::warn!(%error, "🔐 failed to assert that all signers have signed with the new aggregate key; aborting");
})?;
tracing::info!("🔐 all signers have signed with the new aggregate key; proceeding");
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: should we verify here that the signature is valid as well? To avoid having the coordinator reporting DKG verification successful and attempting to sign the rotate key (that will fail, as the signers would refuse)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah good idea

Copy link
Collaborator

Choose a reason for hiding this comment

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

WSTS will only return OperationResult::SignTaproot if the signature passes verification. But there's no harm in verifying it again, it only takes milliseconds.

Some(OperationResult::SignSchnorr(_)) => {
tracing::info!("successfully completed pre-rotate-key validation signing round");
self.wsts_frost_results.put(id, true);
Some(OperationResult::SignSchnorr(sig) | OperationResult::SignTaproot(sig)) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is SignSchnorr still relevant?

Copy link
Member Author

Choose a reason for hiding this comment

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

Think I just left it since it's what's done in the coordinator, but we're not requesting a SignatureType::Schnorr anymore so it can probably be removed without issue.

@@ -152,7 +154,7 @@ pub struct TxSignerEventLoop<Context, Network, Rng> {
/// verification of the Stacks rotate-keys transaction.
pub wsts_frost_state_machines: LruCache<StateMachineId, FrostCoordinator>,
/// Results of the FROST verification rounds.
pub wsts_frost_results: LruCache<StateMachineId, bool>,
pub wsts_frost_mock_txs: LruCache<StateMachineId, UnsignedMockTransaction>,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should we merge this with the above (ie, LruCache<StateMachineId, (FrostCoordinator, UnsignedMockTransaction)>, or wrapping both in a struct)? So that we are sure we always have both available, given that we have an lru and the state machine usage can be decoupled from the mock transaction we may end up in some strange edge case

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah I've been wanting to do this, just haven't gotten to it yet -- will try to get it done soon

Copy link
Collaborator

@xoloki xoloki left a comment

Choose a reason for hiding this comment

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

Looks good modulo a few comments and questions.

Cargo.toml Outdated
@@ -108,4 +109,4 @@ split-debuginfo = "unpacked"
[profile.release]
lto = "thin"
codegen-units = 16
overflow-checks = true
overflow-checks = true
Copy link
Collaborator

Choose a reason for hiding this comment

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

No newline at EOF

Copy link
Member Author

Choose a reason for hiding this comment

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


// We specify the flags to include all pre-taproot and taproot
// verifications explicitly.
// https://github.com/rust-bitcoin/rust-bitcoinconsensus/blob/master/src/lib.rs
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why include pre-taproot?

Copy link
Member Author

Choose a reason for hiding this comment

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

I had tried it without and it fails without the other flags; it seems like there's some sort of roll-up. They have a method for getting the flags based on block height, where they additively enable each flag per activation block height. This is what they do in their tests, anyway.

/// The rotate-key frost verification signing round failed for the aggregate
/// key.
#[error("rotate-key frost verification signing failed for aggregate key: {0}")]
DkgVerificationFailed(Box<PublicKeyXOnly>),
Copy link
Collaborator

Choose a reason for hiding this comment

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

I was about to ask the same :)

.inspect_err(|error| {
tracing::warn!(%error, "🔐 failed to assert that all signers have signed with the new aggregate key; aborting");
})?;
tracing::info!("🔐 all signers have signed with the new aggregate key; proceeding");
Copy link
Collaborator

Choose a reason for hiding this comment

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

WSTS will only return OperationResult::SignTaproot if the signature passes verification. But there's no harm in verifying it again, it only takes milliseconds.

@@ -714,7 +706,7 @@ where
let (id, aggregate_key) = match msg.id {
WstsMessageId::Dkg(_) => {
tracing::warn!(
"received nonce-request for DKG round, which is not supported"
"🔐 received nonce-request for DKG round, which is not supported"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Putting emoji in logs still seems bizarre. Welcome to the future!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
breaking-protocol Breaking protocol changes key rotation The functionality to rotate a private key for a signer in sBTC-v1. sbtc signer binary The sBTC Bootstrap Signer. signer coordination The actions executed by the signer coordinator.
Projects
Status: In Progress
Development

Successfully merging this pull request may close these issues.

4 participants