-
Notifications
You must be signed in to change notification settings - Fork 16
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
base: feat/require-all-signatures-for-rotate-keys
Are you sure you want to change the base?
Changes from all commits
b0cf9a2
623bda8
1526b83
dc4da30
2c3b83d
b7bddbf
6a7073e
3af03bb
15bc545
3349fba
2742370
978a83e
abaf93f
2aff467
15ca645
13595bf
2c46ea5
3fd47be
7e621d7
4642e66
372b55d
3f7b9ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
-- Enum table for DKG shares status | ||
CREATE TABLE sbtc_signer.dkg_shares_status ( | ||
-- The id of the status, not auto-incremented as we want to control the values. | ||
id INTEGER PRIMARY KEY, | ||
-- The name of the status. | ||
key TEXT NOT NULL, | ||
-- Brief description of what the status means. | ||
description TEXT NOT NULL | ||
); | ||
|
||
-- Insert the initial entries. | ||
INSERT INTO sbtc_signer.dkg_shares_status (id, key, description) VALUES | ||
(0, 'PENDING', 'DKG round successful, pending verification via signing round'), | ||
(1, 'VERIFIED', 'Successfully verified via signing round'), | ||
(2, 'KEY_REVOKED', 'The DKG key has been revoked and should not be used'); | ||
|
||
-- Add the new columns to the `dkg_shares` table. We're not adding indexes for | ||
-- now because the table is so small that the overhead likely outweighs the | ||
-- benefits. | ||
ALTER TABLE sbtc_signer.dkg_shares | ||
-- Contains the current | ||
ADD COLUMN dkg_shares_status_id INTEGER DEFAULT 0 REFERENCES sbtc_signer.dkg_shares_status (id), | ||
ADD COLUMN verified_at_bitcoin_block_hash BYTEA DEFAULT NULL, | ||
ADD COLUMN verified_at_bitcoin_block_height BIGINT DEFAULT NULL, | ||
ADD CONSTRAINT fk_dkg_shares_bitcoin_block_hash | ||
FOREIGN KEY (verified_at_bitcoin_block_hash) | ||
REFERENCES sbtc_signer.bitcoin_blocks (block_hash), | ||
ADD CONSTRAINT chk_verified_at | ||
CHECK ( | ||
(dkg_shares_status_id = 1 AND verified_at_bitcoin_block_hash IS NOT NULL AND verified_at_bitcoin_block_height IS NOT NULL) | ||
OR (dkg_shares_status_id <> 1 AND verified_at_bitcoin_block_hash IS NULL AND verified_at_bitcoin_block_height IS NULL) | ||
); | ||
|
||
-- Set all of the current `dkg_shares` to `3` (revoked) to start with. Confirmed | ||
-- DKG shares will be updated to `1` (verified) in the next step. | ||
UPDATE sbtc_signer.dkg_shares | ||
SET dkg_shares_status_id = 3; | ||
|
||
-- Update the `dkg_shares` which have been included in a | ||
-- `rotate_keys_transactions` which can also be tied to a bitcoin block to `1` | ||
-- (verified) and set the `verified_at_*` fields to the bitcoin block | ||
-- hash/height corresponding to the block where these were anchored. | ||
-- | ||
-- This update is not fork aware, but at the time of writing there is no forks | ||
-- that should be problematic (i.e. we shouldn't have any rotate-keys events | ||
-- that have been orphaned). | ||
WITH updated_shares AS ( | ||
SELECT | ||
s.aggregate_key, | ||
bb.block_hash AS verified_at_bitcoin_block_hash, | ||
bb.block_height AS verified_at_bitcoin_block_height | ||
FROM sbtc_signer.dkg_shares s | ||
INNER JOIN sbtc_signer.rotate_keys_transactions rkt | ||
ON s.aggregate_key = rkt.aggregate_key | ||
INNER JOIN sbtc_signer.stacks_transactions stx | ||
ON rkt.txid = stx.txid | ||
INNER JOIN sbtc_signer.stacks_blocks sb | ||
ON stx.block_hash = sb.block_hash | ||
INNER JOIN sbtc_signer.bitcoin_blocks bb | ||
ON sb.bitcoin_anchor = bb.block_hash | ||
ORDER BY bb.block_height DESC | ||
LIMIT 1 | ||
) | ||
UPDATE sbtc_signer.dkg_shares | ||
SET | ||
dkg_shares_status_id = 1, | ||
verified_at_bitcoin_block_hash = updated_shares.verified_at_bitcoin_block_hash, | ||
verified_at_bitcoin_block_height = updated_shares.verified_at_bitcoin_block_height | ||
FROM updated_shares | ||
WHERE sbtc_signer.dkg_shares.aggregate_key = updated_shares.aggregate_key; | ||
|
||
-- Make the `dkg_shares_status_id` column `NOT NULL` now that they should all | ||
-- have a value. | ||
ALTER TABLE sbtc_signer.dkg_shares | ||
ALTER COLUMN dkg_shares_status_id SET NOT NULL; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,6 +5,7 @@ use std::ops::Deref as _; | |
use std::sync::LazyLock; | ||
|
||
use bitcoin::absolute::LockTime; | ||
use bitcoin::consensus::Encodable; | ||
use bitcoin::hashes::Hash as _; | ||
use bitcoin::sighash::Prevouts; | ||
use bitcoin::sighash::SighashCache; | ||
|
@@ -712,6 +713,22 @@ impl SignerUtxo { | |
} | ||
} | ||
|
||
/// A struct for constructing a mock transaction that can be signed. This is | ||
/// used as part of the verification process after a new DKG round has been | ||
/// completed. | ||
/// | ||
/// The Bitcoin transaction has the following layout: | ||
/// 1. The first input is spending the signers' UTXO. | ||
/// 2. There is only one output which is an OP_RETURN with the bytes [0x01, | ||
/// 0x02, 0x03] as the data and amount equal to the UTXO's value (i.e. the | ||
/// transaction has a zero-fee). | ||
pub struct UnsignedMockTransaction { | ||
/// The Bitcoin transaction that needs to be signed. | ||
tx: Transaction, | ||
/// The signers' UTXO used as an input to this transaction. | ||
utxo: SignerUtxo, | ||
} | ||
|
||
/// Given a set of requests, create a BTC transaction that can be signed. | ||
/// | ||
/// This BTC transaction in this struct has correct amounts but no witness | ||
|
@@ -802,6 +819,94 @@ impl SignatureHashes<'_> { | |
} | ||
} | ||
|
||
impl UnsignedMockTransaction { | ||
const AMOUNT: u64 = 0; | ||
|
||
/// Construct an unsigned mock transaction. | ||
/// | ||
/// This will use the provided `aggregate_key` and `amount` to | ||
/// construct a [`Transaction`] with a single input and output. | ||
pub fn new(signer_public_key: XOnlyPublicKey) -> Self { | ||
let utxo = SignerUtxo { | ||
outpoint: OutPoint::null(), | ||
amount: Self::AMOUNT, | ||
public_key: signer_public_key, | ||
}; | ||
|
||
let tx = Transaction { | ||
version: Version::TWO, | ||
lock_time: LockTime::ZERO, | ||
input: vec![utxo.as_tx_input(&DUMMY_SIGNATURE)], | ||
output: vec![TxOut { | ||
value: Amount::from_sat(Self::AMOUNT), | ||
script_pubkey: ScriptBuf::new_op_return([]), | ||
}], | ||
}; | ||
|
||
Self { tx, utxo } | ||
} | ||
|
||
/// Gets the sighash for the signers' input UTXO which needs to be signed | ||
/// before the transaction can be broadcast. | ||
pub fn compute_sighash(&self) -> Result<TapSighash, Error> { | ||
let prevouts = [self.utxo.as_tx_output()]; | ||
let mut sighasher = SighashCache::new(&self.tx); | ||
|
||
sighasher | ||
.taproot_key_spend_signature_hash(0, &Prevouts::All(&prevouts), TapSighashType::All) | ||
.map_err(Into::into) | ||
} | ||
|
||
/// Tests if the provided taproot [`Signature`] is valid for spending the | ||
/// signers' UTXO. This function will return [`Error::BitcoinConsensus`] | ||
/// error if the signature fails verification, passing the underlying error | ||
/// from [`bitcoinconsensus`]. | ||
pub fn verify_signature(&self, signature: &Signature) -> Result<(), Error> { | ||
// Create a copy of the transaction so that we don't modify the | ||
// transaction stored in the struct. | ||
let mut tx = self.tx.clone(); | ||
|
||
// Set the witness data on the input from the provided signature. | ||
tx.input[0].witness = Witness::p2tr_key_spend(signature); | ||
|
||
// 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::BitcoinConsensus) | ||
} | ||
} | ||
|
||
impl<'a> UnsignedTransaction<'a> { | ||
/// Construct an unsigned transaction. | ||
/// | ||
|
@@ -1457,6 +1562,7 @@ mod tests { | |
use std::str::FromStr; | ||
|
||
use super::*; | ||
use bitcoin::key::TapTweak; | ||
use bitcoin::BlockHash; | ||
use bitcoin::CompressedPublicKey; | ||
use bitcoin::Txid; | ||
|
@@ -1652,6 +1758,88 @@ mod tests { | |
} | ||
} | ||
|
||
/// This test verifies that our implementation of Bitcoin script | ||
/// verification using [`bitcoinconsensus`] works as expected. This | ||
/// functionality is used in the verification of WSTS signing after a new | ||
/// DKG round has completed. | ||
#[test] | ||
fn mock_signer_utxo_signing_and_spending_verification() { | ||
let secp = secp256k1::Secp256k1::new(); | ||
|
||
// Generate a key pair which will serve as the signers' aggregate key. | ||
let secret_key = SecretKey::new(&mut OsRng); | ||
let keypair = secp256k1::Keypair::from_secret_key(&secp, &secret_key); | ||
let tweaked = keypair.tap_tweak(&secp, None); | ||
let (aggregate_key, _) = keypair.x_only_public_key(); | ||
|
||
// Create a new transaction using the aggregate key. | ||
let unsigned = UnsignedMockTransaction::new(aggregate_key); | ||
|
||
let tapsig = unsigned | ||
.compute_sighash() | ||
.expect("failed to compute taproot sighash"); | ||
|
||
// Sign the taproot sighash. | ||
let message = secp256k1::Message::from_digest_slice(tapsig.as_byte_array()) | ||
.expect("Failed to create message"); | ||
|
||
// [1] Verify the correct signature, which should succeed. | ||
let schnorr_sig = secp.sign_schnorr(&message, &tweaked.to_inner()); | ||
let taproot_sig = bitcoin::taproot::Signature { | ||
signature: schnorr_sig, | ||
sighash_type: TapSighashType::All, | ||
}; | ||
unsigned | ||
.verify_signature(&taproot_sig) | ||
.expect("signature verification failed"); | ||
|
||
// [2] Verify the correct signature, but with a different sighash type, | ||
// which should fail. | ||
let taproot_sig = bitcoin::taproot::Signature { | ||
signature: schnorr_sig, | ||
sighash_type: TapSighashType::None, | ||
}; | ||
unsigned | ||
.verify_signature(&taproot_sig) | ||
.expect_err("signature verification should have failed"); | ||
|
||
// [3] Verify an incorrect signature with the correct sighash type, | ||
// which should fail. In this case we've created the signature using | ||
// the untweaked keypair. | ||
let schnorr_sig = secp.sign_schnorr(&message, &keypair); | ||
let taproot_sig = bitcoin::taproot::Signature { | ||
signature: schnorr_sig, | ||
sighash_type: TapSighashType::All, | ||
}; | ||
unsigned | ||
.verify_signature(&taproot_sig) | ||
.expect_err("signature verification should have failed"); | ||
|
||
// [4] Verify an incorrect signature with the correct sighash type, which | ||
// should fail. In this case we use a completely newly generated keypair. | ||
let secret_key = SecretKey::new(&mut OsRng); | ||
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, | ||
}; | ||
Comment on lines
+1821
to
+1826
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we also test with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yup, added in 4642e66 |
||
unsigned | ||
.verify_signature(&taproot_sig) | ||
.expect_err("signature verification should have failed"); | ||
|
||
// [5] Same as [4], but using its tweaked key. | ||
let tweaked = keypair.tap_tweak(&secp, None); | ||
let schnorr_sig = secp.sign_schnorr(&message, &tweaked.to_inner()); | ||
let taproot_sig = bitcoin::taproot::Signature { | ||
signature: schnorr_sig, | ||
sighash_type: TapSighashType::All, | ||
}; | ||
unsigned | ||
.verify_signature(&taproot_sig) | ||
.expect_err("signature verification should have failed"); | ||
} | ||
|
||
#[ignore = "For generating the SOLO_(DEPOSIT|WITHDRAWAL)_SIZE constants"] | ||
#[test] | ||
fn create_deposit_only_tx() { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why include pre-taproot?
There was a problem hiding this comment.
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.