diff --git a/Cargo.lock b/Cargo.lock index 64e03642..ef6900be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1061,9 +1061,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" dependencies = [ "cmov", ] @@ -3321,9 +3321,9 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb" dependencies = [ "bitflags 2.13.0", "memchr", diff --git a/ddk-manager/src/contract_updater.rs b/ddk-manager/src/contract_updater.rs index ae1a4713..3a143134 100644 --- a/ddk-manager/src/contract_updater.rs +++ b/ddk-manager/src/contract_updater.rs @@ -914,7 +914,7 @@ where &funding_signatures.witness_elements[0].witness, &own_fund_pubkey, &counter_party_fund_pubkey, - ); + )?; log_debug!( logger, diff --git a/ddk/src/contract/advanced.rs b/ddk/src/contract/advanced.rs index 69a5f657..35f9273c 100644 --- a/ddk/src/contract/advanced.rs +++ b/ddk/src/contract/advanced.rs @@ -75,6 +75,18 @@ pub fn create_cet_adaptor_signatures( /// Verifies one party's refund and CET adaptor signatures. /// /// `party` names the party that produced the signatures. +/// +/// This checks signatures for a contract being negotiated, before its sign +/// message exists, so the transactions are rebuilt under the current +/// [`ddk_dlc::FeeRule`] like every new contract. A contract signed under an +/// earlier rule (see +/// [`create_signed_dlc_transactions`](super::create_signed_dlc_transactions)) +/// fails here even though its signatures are valid; verify a signed contract +/// with [`verify_signed_contract`](super::verify_signed_contract), which +/// selects the rule from the sign message's contract id. Without a sign +/// message there is no contract id to select by, and accepting signatures +/// under whichever rule they happen to verify would approve them for +/// transactions the new contract will not use. pub fn verify_cet_adaptor_signatures( offer: &OfferDlc, accept: &AcceptDlc, @@ -88,7 +100,7 @@ pub fn verify_cet_adaptor_signatures( Party::Offer => (offer.funding_pubkey, ContractError::InvalidSign), Party::Accept => (accept.funding_pubkey, ContractError::InvalidAccept), }; - context::verify_counterparty_signatures( + context::verify_party_signatures( &secp, &context, offer.get_total_collateral(), diff --git a/ddk/src/contract/context.rs b/ddk/src/contract/context.rs index 633ecbad..687755d8 100644 --- a/ddk/src/contract/context.rs +++ b/ddk/src/contract/context.rs @@ -5,13 +5,15 @@ //! never have to supply, store, or trust intermediate transaction data. use bitcoin::consensus::Decodable; -use bitcoin::{Amount, ScriptBuf, Transaction, Witness}; +use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, TxOut, Witness}; use ddk_dlc::secp256k1_zkp::{All, EcdsaAdaptorSignature, PublicKey, Secp256k1, SecretKey}; use ddk_dlc::{DlcTransactions, FeeRule, PartyParams as DlcPartyParams, TxInputInfo}; use ddk_manager::contract::contract_info::ContractInfo as ExecutionContractInfo; use ddk_manager::contract::transactions::{build_contract_transactions, ContractTransactions}; +use ddk_manager::contract::AdaptorInfo; use ddk_messages::{ - AcceptDlc, CetAdaptorSignatures, FundingInput, FundingSignatures, OfferDlc, SignDlc, + AcceptDlc, CetAdaptorSignatures, DlcInput, FundingInput, FundingSignature, FundingSignatures, + OfferDlc, SignDlc, }; use super::error::ContractError; @@ -179,22 +181,10 @@ fn tx_input_infos( let mut input_amount = Amount::ZERO; let mut inputs = Vec::with_capacity(funding_inputs.len()); for input in funding_inputs { - let previous_transaction = decode_previous_transaction(input)?; - let prevout = previous_transaction - .output - .get(input.prev_tx_vout as usize) - .ok_or_else(|| { - ContractError::InvalidFundingInput(format!( - "previous output {} does not exist", - input.prev_tx_vout - )) - })?; + let (outpoint, prevout) = previous_output(input)?; input_amount += prevout.value; inputs.push(TxInputInfo { - outpoint: bitcoin::OutPoint { - txid: previous_transaction.compute_txid(), - vout: input.prev_tx_vout, - }, + outpoint, max_witness_len: input.max_witness_len as usize, redeem_script: input.redeem_script.clone(), serial_id: input.input_serial_id, @@ -203,6 +193,26 @@ fn tx_input_infos( Ok((inputs, input_amount)) } +/// The outpoint a funding input spends and the output found there. +fn previous_output(input: &FundingInput) -> Result<(OutPoint, TxOut), ContractError> { + let previous_transaction = decode_previous_transaction(input)?; + let prevout = previous_transaction + .output + .get(input.prev_tx_vout as usize) + .ok_or_else(|| { + ContractError::InvalidFundingInput(format!( + "previous output {} does not exist", + input.prev_tx_vout + )) + })? + .clone(); + let outpoint = OutPoint { + txid: previous_transaction.compute_txid(), + vout: input.prev_tx_vout, + }; + Ok((outpoint, prevout)) +} + /// Creates this party's CET adaptor signatures and groups them per execution info. pub(crate) fn create_adaptor_signatures( secp: &Secp256k1, @@ -242,46 +252,83 @@ pub(crate) fn create_refund_signature( )?) } -/// Verifies the counterparty's refund and CET adaptor signatures. +/// Verifies one party's refund and CET adaptor signatures. /// /// `error` attributes failures to the message that carried the signatures /// (accept or sign). -pub(crate) fn verify_counterparty_signatures( +pub(crate) fn verify_party_signatures( secp: &Secp256k1, context: &ContractContext, total_collateral: Amount, - counterparty_funding_pubkey: PublicKey, + funding_pubkey: PublicKey, refund_signature: &ddk_dlc::secp256k1_zkp::ecdsa::Signature, adaptor_signatures: &CetAdaptorSignatures, error: fn(String) -> ContractError, ) -> Result<(), ContractError> { - let funding_value = context.transactions.get_fund_output().value; + verify_refund_signature(secp, context, funding_pubkey, refund_signature, error)?; + let adaptor_signatures: Vec = adaptor_signatures.into(); + verify_adaptor_signatures( + secp, + context, + total_collateral, + funding_pubkey, + &adaptor_signatures, + error, + )?; + Ok(()) +} + +/// Verifies one party's refund transaction signature. +pub(crate) fn verify_refund_signature( + secp: &Secp256k1, + context: &ContractContext, + funding_pubkey: PublicKey, + refund_signature: &ddk_dlc::secp256k1_zkp::ecdsa::Signature, + error: fn(String) -> ContractError, +) -> Result<(), ContractError> { ddk_dlc::verify_tx_input_sig( secp, refund_signature, &context.transactions.refund, 0, &context.transactions.funding_witness_script, - funding_value, - &counterparty_funding_pubkey, + context.transactions.get_fund_output().value, + &funding_pubkey, ) - .map_err(|e| error(format!("invalid refund signature: {e}")))?; + .map_err(|e| error(format!("invalid refund signature: {e}"))) +} - let signatures: Vec = adaptor_signatures.into(); +/// Verifies one party's CET adaptor signatures. +/// +/// Returns, for each execution info, its adaptor information and the index of +/// its first adaptor signature, which settlement needs to find the CET and +/// adaptor signature for an outcome. Every signature must be used: a set with +/// extra signatures is rejected. +pub(crate) fn verify_adaptor_signatures( + secp: &Secp256k1, + context: &ContractContext, + total_collateral: Amount, + funding_pubkey: PublicKey, + signatures: &[EcdsaAdaptorSignature], + error: fn(String) -> ContractError, +) -> Result, ContractError> { + let funding_value = context.transactions.get_fund_output().value; + let mut adaptor_infos = Vec::with_capacity(context.execution_infos.len()); let mut signature_index = 0; for (info, range) in context.execution_infos.iter().zip(&context.cet_ranges) { - let (_, next_index) = info + let (adaptor_info, next_index) = info .verify_and_get_adaptor_info( secp, total_collateral, - &counterparty_funding_pubkey, + &funding_pubkey, &context.transactions.funding_witness_script, funding_value, &context.transactions.cets[range.clone()], - &signatures, + signatures, signature_index, ) .map_err(|e| error(format!("invalid CET adaptor signatures: {e}")))?; + adaptor_infos.push((adaptor_info, signature_index)); signature_index = next_index; } if signature_index != signatures.len() { @@ -291,10 +338,136 @@ pub(crate) fn verify_counterparty_signatures( signature_index ))); } + Ok(adaptor_infos) +} + +/// Verifies the offering party's funding witnesses carried by the sign +/// message against the funding transaction. +/// +/// Each ordinary input's witness must spend its previous output (see +/// [`ddk_dlc::verify_funding_witness`]). Each splice input carries the +/// offering party's half of the previous contract's 2-of-2 signature, which +/// must verify against that contract's offer-side funding key +/// (`local_fund_pubkey`). The accepting party's funding witnesses never travel +/// in the messages, so they cannot be checked here. +/// +/// A witness whose script type cannot be checked fails with +/// [`ContractError::UnverifiableFundingWitness`], and one longer than its +/// input's `max_witness_len` with [`ContractError::FundingWitnessTooLong`]. +pub(crate) fn verify_offer_funding_signatures( + secp: &Secp256k1, + offer: &OfferDlc, + accept: &AcceptDlc, + funding_transaction: &Transaction, + signatures: &FundingSignatures, +) -> Result<(), ContractError> { + let signatures = &signatures.funding_signatures; + if signatures.len() != offer.funding_inputs.len() { + return Err(ContractError::InvalidSign(format!( + "sign message carries {} funding signatures but the offer has {} funding inputs", + signatures.len(), + offer.funding_inputs.len() + ))); + } + let prevouts = funding_prevouts(offer, accept)?; + let (input_infos, _) = tx_input_infos(&offer.funding_inputs)?; + for ((input, input_info), signature) in offer + .funding_inputs + .iter() + .zip(&input_infos) + .zip(signatures) + { + let input_serial_id = input.input_serial_id; + let input_index = funding_input_index(offer, accept, input_serial_id)?; + let result = match &input.dlc_input { + Some(dlc_input) => ddk_dlc::dlc_input::verify_dlc_funding_input_signature( + secp, + funding_transaction, + input_index, + &input.into(), + splice_half(input_serial_id, dlc_input, signature)?.to_vec(), + &dlc_input.local_fund_pubkey, + ), + None => ddk_dlc::verify_funding_witness( + secp, + funding_transaction, + input_index, + input_info, + &prevouts, + &funding_witness(signature), + ), + }; + result.map_err(|e| match e { + ddk_dlc::Error::UnsupportedScriptType => { + ContractError::UnverifiableFundingWitness { input_serial_id } + } + ddk_dlc::Error::WitnessTooLong { + witness_len, + max_witness_len, + } => ContractError::FundingWitnessTooLong { + input_serial_id, + witness_len, + max_witness_len, + }, + e => ContractError::InvalidSign(format!( + "invalid funding signature for input serial id {input_serial_id}: {e}" + )), + })?; + } Ok(()) } +/// The offering party's half of a splice input's 2-of-2 signature, from its +/// funding signature in the sign message. +/// +/// The funding signature is the half alone, or the half followed by the +/// serialized offer-side funding key it is checked against (the shape some +/// wallets send). Anything else is rejected rather than silently dropped. +pub(crate) fn splice_half<'a>( + input_serial_id: u64, + dlc_input: &DlcInput, + signature: &'a FundingSignature, +) -> Result<&'a [u8], ContractError> { + match signature.witness_elements.as_slice() { + [half] => Ok(&half.witness), + [half, key] if key.witness == dlc_input.local_fund_pubkey.serialize() => Ok(&half.witness), + elements => Err(ContractError::InvalidSign(format!( + "DLC input serial id {input_serial_id}: expected the half signature, optionally \ + followed by the offer's previous funding key, got {} witness elements", + elements.len() + ))), + } +} + +fn funding_witness(signature: &FundingSignature) -> Witness { + Witness::from_slice( + &signature + .witness_elements + .iter() + .map(|element| element.witness.as_slice()) + .collect::>(), + ) +} + +/// The outputs spent by the funding transaction, in its input order (ascending +/// serial id across both parties). +fn funding_prevouts(offer: &OfferDlc, accept: &AcceptDlc) -> Result, ContractError> { + let inputs = offer.funding_inputs.iter().chain(&accept.funding_inputs); + let prevouts = inputs + .clone() + .map(|input| Ok(previous_output(input)?.1)) + .collect::, ContractError>>()?; + let serial_ids = inputs + .map(|input| input.input_serial_id) + .collect::>(); + Ok(ddk_dlc::util::order_by_serial_ids(prevouts, &serial_ids)) +} + /// Applies one party's funding witnesses to the funding transaction. +/// +/// The witnesses are copied, not checked. The offering party's come from the +/// sign message and are checked by [`verify_offer_funding_signatures`] first; +/// the accepting party's are its own. pub(crate) fn apply_funding_signatures( transaction: &mut Transaction, offer: &OfferDlc, @@ -323,13 +496,7 @@ pub(crate) fn apply_funding_signatures( ))); } let index = funding_input_index(offer, accept, input.input_serial_id)?; - transaction.input[index].witness = Witness::from_slice( - &signature - .witness_elements - .iter() - .map(|element| element.witness.clone()) - .collect::>(), - ); + transaction.input[index].witness = funding_witness(signature); } Ok(()) } diff --git a/ddk/src/contract/error.rs b/ddk/src/contract/error.rs index 706dcfb0..a906cd05 100644 --- a/ddk/src/contract/error.rs +++ b/ddk/src/contract/error.rs @@ -35,6 +35,32 @@ pub enum ContractError { /// The index of the input in the funding transaction. input_index: usize, }, + /// A funding witness spends a script type DDK cannot verify (see + /// [`ddk_dlc::verify_funding_witness`]), so the contract cannot be + /// verified as a whole. + #[error( + "the funding witness of input serial id {input_serial_id} spends a script type that \ + cannot be verified" + )] + UnverifiableFundingWitness { + /// The serial id of the funding input. + input_serial_id: u64, + }, + /// A funding witness is longer than the `max_witness_len` its input + /// declared. The funding fee was computed from the declared length, so the + /// longer witness pays less than the agreed fee rate. + #[error( + "the funding witness of input serial id {input_serial_id} is {witness_len} bytes, over \ + its declared maximum of {max_witness_len}" + )] + FundingWitnessTooLong { + /// The serial id of the funding input. + input_serial_id: u64, + /// The serialized size of the witness. + witness_len: usize, + /// The size the funding input declared. + max_witness_len: usize, + }, /// An oracle attestation is malformed, forged, or does not correspond to /// the announcement of the oracle it claims to come from. #[error("invalid attestation: {0}")] diff --git a/ddk/src/contract/finalize.rs b/ddk/src/contract/finalize.rs index f838fbd3..e461b0c8 100644 --- a/ddk/src/contract/finalize.rs +++ b/ddk/src/contract/finalize.rs @@ -6,16 +6,19 @@ use ddk_dlc::dlc_input::DlcInputInfo; use ddk_dlc::secp256k1_zkp::{PublicKey, Secp256k1}; use ddk_messages::{AcceptDlc, FundingSignatures, OfferDlc, SignDlc}; -use super::context::{ - apply_funding_signatures, context_from_messages, ensure_sign_message, funding_input_index, - verify_counterparty_signatures, ContractContext, -}; +use super::context::{apply_funding_signatures, funding_input_index, splice_half}; use super::error::ContractError; use super::psbt::{ensure_psbt_matches_funding_transaction, extract_funding_signatures}; use super::types::{DlcInputSigningKey, Party}; +use super::verify::{verify_signed_contract, VerifiedContract}; /// Verifies the sign message and completes the funding transaction. /// +/// The three messages are checked with +/// [`verify_signed_contract`](super::verify_signed_contract) first, so a sign +/// message with a bad signature or funding witness, or a funding witness DDK +/// cannot verify, is rejected before any transaction is returned. +/// /// `signed_funding_psbt` must contain finalized witnesses for every /// accept-side funding input; for single-funded contracts with no accept-side /// inputs the unsigned funding PSBT is sufficient. The returned transaction is @@ -47,16 +50,16 @@ pub fn finalize_sign_spliced( signed_funding_psbt: &Psbt, dlc_input_keys: &[DlcInputSigningKey], ) -> Result { - let context = context_from_messages(offer, accept)?; - ensure_psbt_matches_funding_transaction(signed_funding_psbt, &context.transactions.fund)?; + let contract = verify_signed_contract(offer, accept, sign)?; + ensure_psbt_matches_funding_transaction(signed_funding_psbt, &contract.transactions().fund)?; let funding_signatures = extract_funding_signatures(offer, accept, Party::Accept, signed_funding_psbt)?; - finalize_with_context( + complete_funding_transaction( offer, accept, sign, + &contract, funding_signatures, - context, dlc_input_keys, ) } @@ -69,23 +72,26 @@ pub(crate) fn finalize_sign_internal( funding_signatures: FundingSignatures, dlc_input_keys: &[DlcInputSigningKey], ) -> Result { - let context = context_from_messages(offer, accept)?; - finalize_with_context( + let contract = verify_signed_contract(offer, accept, sign)?; + complete_funding_transaction( offer, accept, sign, + &contract, funding_signatures, - context, dlc_input_keys, ) } -fn finalize_with_context( +/// Adds both parties' funding witnesses to the verified contract's funding +/// transaction: the offering party's from the sign message, this party's from +/// `funding_signatures`, and the combined 2-of-2 witness of each splice input. +fn complete_funding_transaction( offer: &OfferDlc, accept: &AcceptDlc, sign: &SignDlc, + contract: &VerifiedContract, funding_signatures: FundingSignatures, - context: ContractContext, dlc_input_keys: &[DlcInputSigningKey], ) -> Result { if funding_signatures.funding_signatures.len() != accept.funding_inputs.len() { @@ -95,31 +101,7 @@ fn finalize_with_context( funding_signatures.funding_signatures.len() ))); } - ensure_sign_message(offer, sign, &context)?; - if sign.funding_signatures.funding_signatures.len() != offer.funding_inputs.len() { - return Err(ContractError::InvalidSign(format!( - "sign message carries {} funding signatures but the offer has {} funding inputs", - sign.funding_signatures.funding_signatures.len(), - offer.funding_inputs.len() - ))); - } - - let secp = Secp256k1::new(); - verify_counterparty_signatures( - &secp, - &context, - offer.get_total_collateral(), - offer.funding_pubkey, - &sign.refund_signature, - &sign.cet_adaptor_signatures, - ContractError::InvalidSign, - )?; - - // Splice inputs' 2-of-2 signatures are computed over the unsigned funding - // transaction (the SegWit sighash does not commit to other inputs' witnesses), - // matching what the offering party signed. - let unsigned_funding_transaction = context.transactions.fund.clone(); - let mut funding_transaction = context.transactions.fund; + let mut funding_transaction = contract.transactions().fund.clone(); apply_funding_signatures( &mut funding_transaction, offer, @@ -136,19 +118,22 @@ fn finalize_with_context( )?; complete_dlc_input_witnesses( &mut funding_transaction, - &unsigned_funding_transaction, + &contract.transactions().fund, offer, accept, sign, dlc_input_keys, )?; - Ok(funding_transaction) } -/// Verifies the offering party's DLC-input half signatures and combines them -/// with this (accepting) party's half to complete each splice input's 2-of-2 -/// witness on the funding transaction. +/// Combines the offering party's DLC-input half signatures, already verified +/// by [`verify_signed_contract`], with this (accepting) party's half to +/// complete each splice input's 2-of-2 witness on the funding transaction. +/// +/// This party's half is computed over the unsigned funding transaction (the +/// SegWit sighash does not commit to other inputs' witnesses), matching what +/// the offering party signed. fn complete_dlc_input_witnesses( funding_transaction: &mut Transaction, unsigned_funding_transaction: &Transaction, @@ -168,31 +153,6 @@ fn complete_dlc_input_witnesses( }; let input_index = funding_input_index(offer, accept, input.input_serial_id)?; let dlc_input_info: DlcInputInfo = input.into(); - let offer_half = offer_signature - .witness_elements - .first() - .ok_or_else(|| { - ContractError::InvalidSign(format!( - "DLC input serial id {} funding signature is empty", - input.input_serial_id - )) - })? - .witness - .clone(); - ddk_dlc::dlc_input::verify_dlc_funding_input_signature( - &secp, - unsigned_funding_transaction, - input_index, - &dlc_input_info, - offer_half.clone(), - &dlc_input.local_fund_pubkey, - ) - .map_err(|e| { - ContractError::InvalidSign(format!( - "invalid DLC input signature for serial id {}: {e}", - input.input_serial_id - )) - })?; let signing_key = dlc_input_keys .iter() .find(|key| key.input_serial_id == input.input_serial_id) @@ -221,10 +181,10 @@ fn complete_dlc_input_witnesses( ddk_dlc::dlc_input::combine_dlc_input_signatures( &dlc_input_info, &accept_half, - &offer_half, + splice_half(input.input_serial_id, dlc_input, offer_signature)?, &dlc_input.remote_fund_pubkey, &dlc_input.local_fund_pubkey, - ); + )?; } Ok(()) } diff --git a/ddk/src/contract/mod.rs b/ddk/src/contract/mod.rs index b19b2b4e..34c74251 100644 --- a/ddk/src/contract/mod.rs +++ b/ddk/src/contract/mod.rs @@ -79,8 +79,38 @@ //! descriptor signing supports `wpkh()` and `sh(wpkh())`, with or without a //! wildcard. Unsupported script types fail with //! [`ContractError::UnsupportedScriptType`](crate::contract::ContractError::UnsupportedScriptType) rather than producing incomplete -//! signatures. External signers can fund with any script type they can -//! finalize themselves. +//! signatures. An external signer can fund the accepting party's inputs with +//! any script type it can finalize itself. +//! +//! The offering party's inputs are narrower, because the accepting party +//! verifies their witnesses before it completes the funding transaction (see +//! below): they must be native P2WPKH, P2SH-P2WPKH or Taproot key-path spends, +//! signing with `SIGHASH_ALL`. A witness for any other script type cannot be +//! checked, so verification refuses it with +//! [`ContractError::UnverifiableFundingWitness`](crate::contract::ContractError::UnverifiableFundingWitness) +//! rather than passing a contract it has not fully verified. A witness longer +//! than its input's declared `max_witness_len` is refused with +//! [`ContractError::FundingWitnessTooLong`](crate::contract::ContractError::FundingWitnessTooLong): +//! the funding fee was paid on the declared length. +//! +//! # Verifying a signed contract +//! +//! [`verify_signed_contract`](crate::contract::verify_signed_contract) checks +//! that the three messages describe one contract and that every signature they +//! carry is valid: both parties' refund and CET adaptor signatures, the +//! offering party's funding witnesses, and its halves of any splice inputs. A +//! success means all of it was verified; it returns a +//! [`VerifiedContract`](crate::contract::VerifiedContract) holding the +//! contract id and the transactions. +//! [`finalize_sign`](crate::contract::finalize_sign) runs it before completing +//! the funding transaction; call it directly to check messages received or +//! restored from elsewhere. +//! +//! **The accepting party's funding witnesses are not in the three messages**, +//! so they are not part of what `verify_signed_contract` verifies, and no one +//! can verify them from the messages alone. The accepting party adds them +//! when it finalizes, and the funding transaction a +//! [`VerifiedContract`](crate::contract::VerifiedContract) exposes is unsigned. //! //! # Splicing //! @@ -160,6 +190,7 @@ mod settle; mod sign; mod splice; mod types; +mod verify; #[cfg(test)] mod tests; @@ -177,6 +208,7 @@ pub use types::{ chain_hash_from_network, funding_input, AcceptOfferParams, AcceptResult, CreateOfferParams, DescriptorInput, DlcInputSigningKey, InputDerivation, Party, PartyParams, SignResult, }; +pub use verify::{verify_signed_contract, VerifiedContract}; /// The current DLC protocol version used by DDK. pub const PROTOCOL_VERSION: u32 = 1; diff --git a/ddk/src/contract/settle.rs b/ddk/src/contract/settle.rs index f7563389..b7a7fb00 100644 --- a/ddk/src/contract/settle.rs +++ b/ddk/src/contract/settle.rs @@ -10,6 +10,12 @@ //! Like the rest of the module, nothing is stored: the CET set, the refund //! transaction, and the adaptor information are all rebuilt from the offer and //! accept messages on demand. +//! +//! Settlement checks only the counterparty signatures it combines, not the +//! whole contract as [`verify_signed_contract`](super::verify_signed_contract) +//! does. The funding transaction is already on chain, so its witnesses no +//! longer matter, and a refund must stay possible on the counterparty's refund +//! signature alone, however expensive or damaged the rest of the messages are. use bitcoin::Transaction; use ddk_dlc::secp256k1_zkp::{ @@ -18,7 +24,7 @@ use ddk_dlc::secp256k1_zkp::{ use ddk_messages::oracle_msgs::OracleAttestation; use ddk_messages::{AcceptDlc, OfferDlc, SignDlc}; -use super::context::signed_context; +use super::context::{signed_context, verify_adaptor_signatures, verify_refund_signature}; use super::error::ContractError; use super::types::Party; @@ -60,37 +66,32 @@ pub fn sign_cet( let (counterparty_pubkey, adaptor_signatures) = counterparty_adaptor_signatures(offer, accept, sign, party); - let total_collateral = offer.get_total_collateral(); + // Verifying the counterparty's adaptor signatures is also how the adaptor + // info of each execution info is obtained. + let adaptor_infos = verify_adaptor_signatures( + &secp, + &context, + offer.get_total_collateral(), + counterparty_pubkey, + &adaptor_signatures, + counterparty_error(party), + )?; let funding_witness_script = &context.transactions.funding_witness_script; let fund_value = context.transactions.get_fund_output().value; - let mut signature_index = 0; - for (info, cet_range) in context.execution_infos.iter().zip(&context.cet_ranges) { - // Verifying the counterparty's adaptor signatures is also how the - // adaptor info and the next signature offset are obtained. - let (adaptor_info, next_index) = info - .verify_and_get_adaptor_info( - &secp, - total_collateral, - &counterparty_pubkey, - funding_witness_script, - fund_value, - &context.transactions.cets[cet_range.clone()], - &adaptor_signatures, - signature_index, - ) - .map_err(|e| { - counterparty_error(party)(format!("invalid CET adaptor signatures: {e}")) - })?; - + for ((info, cet_range), (adaptor_info, signature_start)) in context + .execution_infos + .iter() + .zip(&context.cet_ranges) + .zip(&adaptor_infos) + { // The lookup also binds the attestations to the oracle combination the // adaptor point was built for: one attestation per oracle, and a // signature set for every oracle of the matched combination. let Some((range_info, oracle_signatures)) = info - .get_range_info_and_oracle_signatures(&adaptor_info, attestations, signature_index) + .get_range_info_and_oracle_signatures(adaptor_info, attestations, *signature_start) .map_err(attestation_error)? else { - signature_index = next_index; continue; }; @@ -140,19 +141,16 @@ pub fn sign_refund( Party::Accept => (offer.funding_pubkey, sign.refund_signature), }; - let funding_witness_script = &context.transactions.funding_witness_script; - let fund_value = context.transactions.get_fund_output().value; - ddk_dlc::verify_tx_input_sig( + verify_refund_signature( &secp, + &context, + counterparty_pubkey, &counterparty_signature, - &context.transactions.refund, - 0, - funding_witness_script, - fund_value, - &counterparty_pubkey, - ) - .map_err(|e| counterparty_error(party)(format!("invalid refund signature: {e}")))?; + counterparty_error(party), + )?; + let funding_witness_script = &context.transactions.funding_witness_script; + let fund_value = context.transactions.get_fund_output().value; let mut refund = context.transactions.refund.clone(); ddk_dlc::util::sign_multi_sig_input( &secp, diff --git a/ddk/src/contract/sign.rs b/ddk/src/contract/sign.rs index a91278ea..0cbe494c 100644 --- a/ddk/src/contract/sign.rs +++ b/ddk/src/contract/sign.rs @@ -8,8 +8,8 @@ use ddk_messages::{AcceptDlc, CetAdaptorSignatures, FundingSignatures, OfferDlc, use super::context::{ context_from_messages, contract_id_from_transactions, create_adaptor_signatures, - create_refund_signature, ensure_funding_key, funding_input_index, - verify_counterparty_signatures, ContractContext, + create_refund_signature, ensure_funding_key, funding_input_index, verify_party_signatures, + ContractContext, }; use super::error::ContractError; use super::psbt::{ensure_psbt_matches_funding_transaction, funding_signature_from_witness}; @@ -164,7 +164,7 @@ fn sign_with_context( &offer.funding_pubkey, ContractError::InvalidOffer, )?; - verify_counterparty_signatures( + verify_party_signatures( &secp, &context, offer.get_total_collateral(), diff --git a/ddk/src/contract/verify.rs b/ddk/src/contract/verify.rs new file mode 100644 index 00000000..df4c0286 --- /dev/null +++ b/ddk/src/contract/verify.rs @@ -0,0 +1,115 @@ +//! Verification of a signed contract from its three wire messages. + +use ddk_dlc::secp256k1_zkp::Secp256k1; +use ddk_dlc::DlcTransactions; +use ddk_messages::{AcceptDlc, OfferDlc, SignDlc}; + +use super::context::{signed_context, verify_offer_funding_signatures, verify_party_signatures}; +use super::error::ContractError; + +/// A contract whose offer, accept and sign messages were checked against each +/// other by [`verify_signed_contract`]. +/// +/// Holding one means every signature the three messages carry is valid for the +/// transactions it exposes. Code that imports, settles or splices the contract +/// can use the transactions without checking them again. +/// +/// The accepting party's funding witnesses are not among those signatures: +/// they are not in the messages, so the funding transaction exposed here is +/// unsigned. +pub struct VerifiedContract { + contract_id: [u8; 32], + transactions: DlcTransactions, +} + +impl VerifiedContract { + /// The contract id named by the sign message, which the rebuilt funding + /// transaction matches. + pub fn contract_id(&self) -> [u8; 32] { + self.contract_id + } + + /// The contract's transactions. The funding transaction is unsigned: the + /// accepting party's funding witnesses are not part of the messages. + pub fn transactions(&self) -> &DlcTransactions { + &self.transactions + } + + /// Takes the contract's transactions. + pub fn into_transactions(self) -> DlcTransactions { + self.transactions + } +} + +/// Verifies that the offer, accept and sign messages describe one signed +/// contract and returns its transactions. +/// +/// **The accepting party's funding witnesses are not verified here.** They, +/// and its halves of any splice inputs, never travel in the three messages: +/// the accepting party adds them in [`finalize_sign`](super::finalize_sign). +/// This function verifies what the messages carry, all of it, and a success +/// means none of it is left unchecked. +/// +/// The transactions are rebuilt under the fee rule whose funding transaction +/// matches the sign message's contract id, as in +/// [`create_signed_dlc_transactions`](super::create_signed_dlc_transactions), +/// so contracts signed before the current rule verify too. Then every +/// signature the messages carry is checked against them: +/// +/// - both parties' refund signatures and CET adaptor signatures (the accept +/// message carries the accepting party's, the sign message the offering +/// party's); +/// - the offering party's funding witnesses from the sign message: each +/// ordinary input's witness against the output it spends (P2WPKH, +/// P2SH-P2WPKH or a Taproot key-path spend, signing with `SIGHASH_ALL`, and +/// no longer than the input's declared `max_witness_len`), and each splice +/// input's half signature against the previous contract's offer-side funding +/// key. +/// +/// An offer funding input of any other script type (a P2WSH multisig, for +/// example) fails with [`ContractError::UnverifiableFundingWitness`]: DDK +/// cannot tell whether its witness is valid, so the contract cannot be +/// verified. A witness longer than its input declared fails with +/// [`ContractError::FundingWitnessTooLong`], since the funding fee was paid on +/// the declared length. Other failures are attributed to the message that +/// carried the bad data: [`ContractError::InvalidAccept`] for the accept +/// message's signatures and [`ContractError::InvalidSign`] for the sign +/// message's. +pub fn verify_signed_contract( + offer: &OfferDlc, + accept: &AcceptDlc, + sign: &SignDlc, +) -> Result { + let context = signed_context(offer, accept, sign)?; + let secp = Secp256k1::new(); + let total_collateral = offer.get_total_collateral(); + verify_party_signatures( + &secp, + &context, + total_collateral, + accept.funding_pubkey, + &accept.refund_signature, + &accept.cet_adaptor_signatures, + ContractError::InvalidAccept, + )?; + verify_party_signatures( + &secp, + &context, + total_collateral, + offer.funding_pubkey, + &sign.refund_signature, + &sign.cet_adaptor_signatures, + ContractError::InvalidSign, + )?; + verify_offer_funding_signatures( + &secp, + offer, + accept, + &context.transactions.fund, + &sign.funding_signatures, + )?; + Ok(VerifiedContract { + contract_id: sign.contract_id, + transactions: context.transactions, + }) +} diff --git a/ddk/tests/stateless.rs b/ddk/tests/stateless.rs index c53b86dc..346d7889 100644 --- a/ddk/tests/stateless.rs +++ b/ddk/tests/stateless.rs @@ -10,16 +10,20 @@ use bitcoin::absolute::LockTime; use bitcoin::bip32::{DerivationPath, Xpriv}; use bitcoin::hashes::Hash; use bitcoin::psbt::Psbt; +use bitcoin::sighash::{EcdsaSighashType, SighashCache}; use bitcoin::transaction::Version; use bitcoin::{Amount, Network, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; use ddk::contract::{ - accept_offer, chain_hash_from_network, create_dlc_splice_input, create_dlc_transactions, - create_funding_psbt, create_offer, create_signed_dlc_transactions, finalize_sign, - finalize_sign_spliced, funding_input, sign_accept, sign_accept_spliced, sign_cet, sign_refund, - signing, validate_offer, AcceptOfferParams, ContractError, CreateOfferParams, DescriptorInput, - DlcInputSigningKey, InputDerivation, Party, PartyParams, DLC_INPUT_MAX_WITNESS_LEN, + accept_offer, advanced, chain_hash_from_network, create_dlc_splice_input, + create_dlc_transactions, create_funding_psbt, create_offer, create_signed_dlc_transactions, + finalize_sign, finalize_sign_spliced, funding_input, sign_accept, sign_accept_spliced, + sign_cet, sign_refund, signing, validate_offer, verify_signed_contract, AcceptOfferParams, + ContractError, CreateOfferParams, DescriptorInput, DlcInputSigningKey, InputDerivation, Party, + PartyParams, DLC_INPUT_MAX_WITNESS_LEN, +}; +use ddk_dlc::secp256k1_zkp::{ + All, Keypair, Message, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey, }; -use ddk_dlc::secp256k1_zkp::{All, Keypair, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey}; use ddk_messages::contract_msgs::{ ContractDescriptor, ContractInfo, ContractInfoInner, ContractOutcome, DisjointContractInfo, EnumeratedContractDescriptor, NumericOutcomeContractDescriptor, SingleContractInfo, @@ -2887,3 +2891,352 @@ fn an_accept_whose_collateral_overflows_the_total_is_rejected() { Err(ContractError::InvalidAccept(_)) )); } + +/// A dual-funded enum contract, signed by both parties through the PSBT +/// lifecycle. Each party funds one P2WPKH UTXO worth 150,000 sats. +fn signed_enum_contract() -> (PartySetup, PartySetup, OfferDlc, AcceptDlc, SignDlc) { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, NETWORK); + let (sign, _) = fund_with_xpriv(&secp, &offerer, &accepter, &offer, &accept); + (offerer, accepter, offer, accept, sign) +} + +/// The index in the funding transaction of the offering party's only input. +fn offer_input_index(offer: &OfferDlc, accept: &AcceptDlc) -> usize { + let previous: Transaction = + bitcoin::consensus::deserialize(&offer.funding_inputs[0].prev_tx).unwrap(); + create_dlc_transactions(offer, accept) + .unwrap() + .fund + .input + .iter() + .position(|input| input.previous_output.txid == previous.compute_txid()) + .unwrap() +} + +#[test] +fn a_signed_contract_verifies_from_its_three_messages() { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, NETWORK); + let (sign, funding_transaction) = fund_with_xpriv(&secp, &offerer, &accepter, &offer, &accept); + + let contract = verify_signed_contract(&offer, &accept, &sign).unwrap(); + assert_eq!(contract.contract_id(), sign.contract_id); + assert_eq!( + contract.transactions().fund.compute_txid(), + funding_transaction.compute_txid() + ); +} + +#[test] +fn a_pre_rc4_single_funded_contract_verifies() { + let contract = pre_rc4_single_funded_contract(); + let verified = + verify_signed_contract(&contract.offer, &contract.accept, &contract.sign).unwrap(); + assert_eq!( + verified.transactions().fund.compute_txid(), + contract.funding_transaction.compute_txid() + ); +} + +#[test] +fn a_tampered_offer_funding_witness_is_rejected() { + let (_, _, offer, accept, mut sign) = signed_enum_contract(); + // Flip a bit of the signature's R value: still a well-formed signature, + // no longer one over the funding transaction. + sign.funding_signatures.funding_signatures[0].witness_elements[0].witness[10] ^= 1; + assert!(matches!( + verify_signed_contract(&offer, &accept, &sign), + Err(ContractError::InvalidSign(_)) + )); +} + +#[test] +fn an_offer_funding_witness_from_a_key_that_does_not_own_the_coin_is_rejected() { + let (_, _, offer, accept, mut sign) = signed_enum_contract(); + let funding_transaction = create_dlc_transactions(&offer, &accept).unwrap().fund; + // A valid signature over the funding transaction, by someone else's key. + sign.funding_signatures.funding_signatures[0] = advanced::sign_p2wpkh_funding_input( + &funding_transaction, + offer_input_index(&offer, &accept), + Amount::from_sat(150_000), + &SecretKey::from_slice(&[77; 32]).unwrap(), + ) + .unwrap(); + assert!(matches!( + verify_signed_contract(&offer, &accept, &sign), + Err(ContractError::InvalidSign(_)) + )); +} + +#[test] +fn an_offer_funding_witness_that_does_not_sign_all_is_rejected() { + let secp = Secp256k1::new(); + let (offerer, _, offer, accept, mut sign) = signed_enum_contract(); + let funding_transaction = create_dlc_transactions(&offer, &accept).unwrap().fund; + // The owner's SIGHASH_NONE signature is valid on chain, but it commits to + // no outputs, so the coin could be spent into any transaction. + let wallet_key = offerer + .xpriv + .derive_priv(&secp, &offerer.derivation_path) + .unwrap() + .private_key; + let public_key = bitcoin::PublicKey::new(wallet_key.public_key(&secp)); + let sighash = SighashCache::new(&funding_transaction) + .p2wpkh_signature_hash( + offer_input_index(&offer, &accept), + &ScriptBuf::new_p2wpkh(&public_key.wpubkey_hash().unwrap()), + Amount::from_sat(150_000), + EcdsaSighashType::None, + ) + .unwrap(); + let signature = bitcoin::ecdsa::Signature { + signature: secp.sign_ecdsa(&Message::from_digest(sighash.to_byte_array()), &wallet_key), + sighash_type: EcdsaSighashType::None, + }; + sign.funding_signatures.funding_signatures[0] = + advanced::funding_signature_from_witness(Witness::from_slice(&[ + signature.to_vec(), + public_key.to_bytes(), + ])); + assert!(matches!( + verify_signed_contract(&offer, &accept, &sign), + Err(ContractError::InvalidSign(_)) + )); +} + +#[test] +fn a_tampered_refund_signature_is_rejected() { + let (_, _, offer, accept, sign) = signed_enum_contract(); + // Each forged refund signature is valid, but by the other party's key. + let mut forged_sign = sign.clone(); + forged_sign.refund_signature = accept.refund_signature; + assert!(matches!( + verify_signed_contract(&offer, &accept, &forged_sign), + Err(ContractError::InvalidSign(_)) + )); + let mut forged_accept = accept.clone(); + forged_accept.refund_signature = sign.refund_signature; + assert!(matches!( + verify_signed_contract(&offer, &forged_accept, &sign), + Err(ContractError::InvalidAccept(_)) + )); +} + +#[test] +fn a_tampered_adaptor_signature_is_rejected() { + let (_, _, offer, accept, sign) = signed_enum_contract(); + // Swapped adaptor signatures are each valid, but for another outcome's CET. + let mut forged_sign = sign.clone(); + forged_sign + .cet_adaptor_signatures + .ecdsa_adaptor_signatures + .swap(0, 1); + assert!(matches!( + verify_signed_contract(&offer, &accept, &forged_sign), + Err(ContractError::InvalidSign(_)) + )); + let mut forged_accept = accept.clone(); + forged_accept + .cet_adaptor_signatures + .ecdsa_adaptor_signatures + .swap(0, 1); + assert!(matches!( + verify_signed_contract(&offer, &forged_accept, &sign), + Err(ContractError::InvalidAccept(_)) + )); +} + +#[test] +fn a_spliced_contract_verifies_and_rejects_a_tampered_half() { + let prepared = prepare_splice(true); + let (offer, accept) = (&prepared.offer_b, &prepared.accept_b); + verify_signed_contract(offer, accept, &prepared.sign).unwrap(); + + // The half's sighash byte no longer says SIGHASH_ALL. The signature still + // verifies against the SIGHASH_ALL digest, so only the encoding check + // catches it before it goes into a witness that fails on chain. + let position = offer + .funding_inputs + .iter() + .position(|input| input.dlc_input.is_some()) + .unwrap(); + let mut tampered = prepared.sign.clone(); + let half = &mut tampered.funding_signatures.funding_signatures[position].witness_elements[0]; + *half.witness.last_mut().unwrap() = EcdsaSighashType::None as u8; + assert!(matches!( + verify_signed_contract(offer, accept, &tampered), + Err(ContractError::InvalidSign(_)) + )); +} + +#[test] +fn a_splice_half_may_carry_its_key_but_nothing_else() { + let prepared = prepare_splice(true); + let (offer, accept) = (&prepared.offer_b, &prepared.accept_b); + let position = offer + .funding_inputs + .iter() + .position(|input| input.dlc_input.is_some()) + .unwrap(); + let offer_key = prepared + .splice_input + .dlc_input + .as_ref() + .unwrap() + .local_fund_pubkey; + let with_trailing = |element: Vec| { + let mut sign = prepared.sign.clone(); + sign.funding_signatures.funding_signatures[position] + .witness_elements + .push(WitnessElement { witness: element }); + sign + }; + + // The half followed by the key that verified it is the shape some + // wallets send; only the half goes on chain. + let with_key = with_trailing(offer_key.serialize().to_vec()); + verify_signed_contract(offer, accept, &with_key).unwrap(); + let accept_splice_key = DlcInputSigningKey { + input_serial_id: prepared.splice_serial, + prior_funding_secret_key: prepared.prior_accept_key, + }; + let funding_tx_b = finalize_sign_spliced( + offer, + accept, + &with_key, + &prepared.accept_psbt, + std::slice::from_ref(&accept_splice_key), + ) + .unwrap(); + assert_splice_input_signed(&funding_tx_b, &prepared); + + // Any other trailing data is rejected, not dropped. + let other_key = PublicKey::from_secret_key(&Secp256k1::new(), &prepared.prior_accept_key); + for element in [other_key.serialize().to_vec(), vec![0]] { + assert!(matches!( + verify_signed_contract(offer, accept, &with_trailing(element)), + Err(ContractError::InvalidSign(_)) + )); + } +} + +#[test] +fn an_offer_funded_from_a_script_ddk_cannot_check_is_refused() { + use bitcoin::opcodes::all::OP_CHECKSIG; + + let secp = Secp256k1::new(); + let offerer = PartySetup::new(&secp, 61, NETWORK, Amount::from_sat(150_000), 1); + let accepter = PartySetup::new(&secp, 62, NETWORK, Amount::from_sat(150_000), 2); + // The offering party funds from a P2WSH coin that an external signer + // spends with ` `. + let wallet_key = SecretKey::from_slice(&[63; 32]).unwrap(); + let witness_script = bitcoin::script::Builder::new() + .push_slice(wallet_key.public_key(&secp).serialize()) + .push_opcode(OP_CHECKSIG) + .into_script(); + let coin_value = Amount::from_sat(150_000); + let previous = previous_transaction( + coin_value, + ScriptBuf::new_p2wsh(&witness_script.wscript_hash()), + ); + let p2wsh_input = + funding_input(&previous, 0, Some(1), u32::MAX, 110, ScriptBuf::new()).unwrap(); + let offer = create_offer(offer_params( + &secp, + &offerer, + enum_contract_info(TOTAL_COLLATERAL), + Amount::from_sat(50_000), + NETWORK, + vec![p2wsh_input], + )) + .unwrap(); + let accept = accept_offer( + &offer, + AcceptOfferParams { + party: accepter.party_params(&secp, vec![accepter.funding_input.clone()]), + min_timeout_interval: MIN_TIMEOUT, + max_timeout_interval: MAX_TIMEOUT, + now_unix: NOW_UNIX, + }, + &accepter.funding_secret_key, + ) + .unwrap() + .accept; + + let mut offer_psbt = create_funding_psbt(&offer, &accept).unwrap(); + let index = offer_input_index(&offer, &accept); + let sighash = SighashCache::new(&offer_psbt.unsigned_tx) + .p2wsh_signature_hash(index, &witness_script, coin_value, EcdsaSighashType::All) + .unwrap(); + let signature = bitcoin::ecdsa::Signature::sighash_all( + secp.sign_ecdsa(&Message::from_digest(sighash.to_byte_array()), &wallet_key), + ); + let witness = Witness::from_slice(&[signature.to_vec(), witness_script.to_bytes()]); + offer_psbt.inputs[index].final_script_witness = Some(witness); + let sign = sign_accept(&offer, &accept, &offerer.funding_secret_key, &offer_psbt) + .unwrap() + .sign; + + // The witness may be valid, but DDK cannot tell, so neither verification + // nor finalization passes the contract. + assert!(matches!( + verify_signed_contract(&offer, &accept, &sign), + Err(ContractError::UnverifiableFundingWitness { input_serial_id: 1 }) + )); + + let mut accept_psbt = create_funding_psbt(&offer, &accept).unwrap(); + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut accept_psbt, + &accepter.xpriv, + &accepter.derivations(), + ) + .unwrap(); + assert!(matches!( + finalize_sign(&offer, &accept, &sign, &accept_psbt), + Err(ContractError::UnverifiableFundingWitness { input_serial_id: 1 }) + )); +} + +#[test] +fn an_offer_funding_witness_longer_than_its_input_declared_is_rejected() { + let (_, _, offer, accept, mut sign) = signed_enum_contract(); + let declared = offer.funding_inputs[0].max_witness_len as usize; + // The funding fee was paid on the declared length. A witness padded past + // it is caught by its size, whatever else is wrong with it. + let elements = &mut sign.funding_signatures.funding_signatures[0].witness_elements; + elements.push(WitnessElement { + witness: vec![0; declared], + }); + assert!(matches!( + verify_signed_contract(&offer, &accept, &sign), + Err(ContractError::FundingWitnessTooLong { + input_serial_id, + max_witness_len, + .. + }) if input_serial_id == offer.funding_inputs[0].input_serial_id + && max_witness_len == declared + )); +} + +#[test] +fn finalize_sign_rejects_a_sign_message_with_a_bad_funding_witness() { + let (_, accepter, offer, accept, mut sign) = signed_enum_contract(); + sign.funding_signatures.funding_signatures[0].witness_elements[0].witness[10] ^= 1; + + let mut accept_psbt = create_funding_psbt(&offer, &accept).unwrap(); + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut accept_psbt, + &accepter.xpriv, + &accepter.derivations(), + ) + .unwrap(); + assert!(matches!( + finalize_sign(&offer, &accept, &sign, &accept_psbt), + Err(ContractError::InvalidSign(_)) + )); +} diff --git a/dlc/src/dlc_input.rs b/dlc/src/dlc_input.rs index 1c15b7cc..dfaaa712 100644 --- a/dlc/src/dlc_input.rs +++ b/dlc/src/dlc_input.rs @@ -1,9 +1,10 @@ //! Module for working with DLC inputs use bitcoin::{Amount, EcdsaSighashType, OutPoint, ScriptBuf, Transaction, Witness}; -use secp256k1_zkp::{ecdsa::Signature, PublicKey, Secp256k1, SecretKey, Signing, Verification}; +use secp256k1_zkp::{PublicKey, Secp256k1, SecretKey, Signing, Verification}; -use crate::{util::finalize_sig, Error, TxInputInfo}; +use crate::util::{finalize_sig, multisig_witness, sighash_all_signature}; +use crate::{Error, TxInputInfo}; #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "use-serde", derive(serde::Serialize, serde::Deserialize))] @@ -79,6 +80,16 @@ pub fn create_dlc_funding_input_signature( } /// Verify a DLC funding input signature +/// +/// `signature` is one party's half of the 2-of-2 witness as it goes on chain: +/// a DER signature followed by the `SIGHASH_ALL` byte, as produced by +/// [`create_dlc_funding_input_signature`]. Any other encoding or sighash type +/// is rejected, since it could not be combined into a valid witness. +/// +/// Changed in 2.0.0: a 64-byte compact signature, or a DER signature with a +/// sighash byte other than `SIGHASH_ALL`, used to be accepted. Callers that +/// hold a raw [`Signature`](secp256k1_zkp::ecdsa::Signature) pass +/// `util::finalize_sig(&signature, EcdsaSighashType::All)` instead. pub fn verify_dlc_funding_input_signature( secp: &Secp256k1, fund_transaction: &Transaction, @@ -88,15 +99,7 @@ pub fn verify_dlc_funding_input_signature( pubkey: &PublicKey, ) -> Result<(), Error> { let funding_script = create_dlc_input_funding_script(dlc_input); - - // Parse DER signature instead of compact - let signature = if signature.len() == 64 { - Signature::from_compact(&signature)? - } else { - // Remove sighash type byte and parse DER - let sig_bytes = &signature[..signature.len() - 1]; - Signature::from_der(sig_bytes)? - }; + let signature = sighash_all_signature(&signature)?; super::verify_tx_input_sig( secp, @@ -110,27 +113,25 @@ pub fn verify_dlc_funding_input_signature( } /// Combine both parties' signatures for a DLC input +/// +/// Both signatures are encoded as [`create_dlc_funding_input_signature`] +/// produces them (DER followed by `SIGHASH_ALL`); anything else is an error +/// rather than a witness that fails on chain. The witness is built by +/// [`multisig_witness`](crate::util::multisig_witness). +/// +/// Changed in 2.0.0: the signatures are `&[u8]` (was `&Vec`, which still +/// coerces, so call sites compile unchanged) and the result is +/// `Result` (was `Witness`): add `?` at the call site. pub fn combine_dlc_input_signatures( dlc_input: &DlcInputInfo, - my_signature: &Vec, - other_signature: &Vec, + my_signature: &[u8], + other_signature: &[u8], my_pubkey: &PublicKey, other_pubkey: &PublicKey, -) -> Witness { - let funding_script = create_dlc_input_funding_script(dlc_input); - - // Order signatures based on pubkey order - let (first_sig, second_sig) = if my_pubkey <= other_pubkey { - (my_signature, other_signature) - } else { - (other_signature, my_signature) - }; - - let mut witness = Witness::new(); - witness.push([]); - witness.push(first_sig); - witness.push(second_sig); - witness.push(funding_script.to_bytes()); - - witness +) -> Result { + Ok(multisig_witness( + &create_dlc_input_funding_script(dlc_input), + (*my_pubkey, sighash_all_signature(my_signature)?), + (*other_pubkey, sighash_all_signature(other_signature)?), + )) } diff --git a/dlc/src/lib.rs b/dlc/src/lib.rs index 55831f45..e4ceea80 100644 --- a/dlc/src/lib.rs +++ b/dlc/src/lib.rs @@ -22,7 +22,9 @@ pub extern crate secp256k1_zkp; #[cfg(feature = "use-serde")] extern crate serde; +use bitcoin::hashes::Hash; use bitcoin::secp256k1::Scalar; +use bitcoin::sighash::{EcdsaSighashType, Prevouts, SighashCache, TapSighashType}; use bitcoin::transaction::Version; use bitcoin::Amount; use bitcoin::{ @@ -231,6 +233,18 @@ pub enum Error { Miniscript(miniscript::Error), /// Error attempting to do an out of bounds access on the transaction inputs vector. InputsIndex(bitcoin::transaction::InputsIndexError), + /// A funding witness spends a script type that + /// [`verify_funding_witness`] cannot check. The witness is neither valid + /// nor invalid as far as this crate can tell. + UnsupportedScriptType, + /// A funding witness is longer than the `max_witness_len` its input + /// declared, which the funding transaction's fee was computed from. + WitnessTooLong { + /// The serialized size of the witness. + witness_len: usize, + /// The size the input declared. + max_witness_len: usize, + }, } impl From for Error { @@ -271,6 +285,19 @@ impl fmt::Display for Error { Error::P2wpkh(ref e) => write!(f, "Error while computing p2wpkh sighash: {e}"), Error::InputsIndex(ref e) => write!(f, "Error ordering inputs: {e}"), Error::Miniscript(_) => write!(f, "Error within miniscript"), + Error::UnsupportedScriptType => { + write!( + f, + "The witness spends a script type that cannot be verified" + ) + } + Error::WitnessTooLong { + witness_len, + max_witness_len, + } => write!( + f, + "The witness is {witness_len} bytes, over the declared maximum of {max_witness_len}" + ), } } } @@ -284,6 +311,7 @@ impl std::error::Error for Error { Error::InputsIndex(e) => Some(e), Error::InvalidArgument(_) => None, Error::Miniscript(e) => Some(e), + Error::UnsupportedScriptType | Error::WitnessTooLong { .. } => None, } } } @@ -1367,6 +1395,147 @@ pub fn verify_tx_input_sig( Ok(()) } +/// Verifies that `witness` spends the ordinary (non-DLC) funding input `input` +/// at `input_index` of the funding transaction `fund_tx`. +/// +/// `prevouts` are the outputs spent by every input of `fund_tx`, in input +/// order: a Taproot signature commits to all of them. +/// +/// Three kinds of coin can be verified: native P2WPKH, P2SH-wrapped P2WPKH +/// (whose program is `input.redeem_script`), and a Taproot key-path spend. +/// For those, the witness must belong to the key that owns the coin and sign +/// the whole transaction: `SIGHASH_ALL`, or Taproot's default, which commits +/// to the same data. A signature that commits to less would let the signer's +/// coin be moved into a different transaction. The input's script signature +/// must be empty for a native coin and the single push of the redeem script +/// for a wrapped one, or the spend is invalid however good the signature is. +/// +/// Any other script type (P2WSH, P2SH-P2WSH, a Taproot script-path spend, ...) +/// returns [`Error::UnsupportedScriptType`]: the witness has not been checked +/// and must not be treated as valid, but it is not known to be invalid either. +/// +/// Whatever the script type, a witness longer than `input.max_witness_len` +/// (its serialized size, as counted in the transaction weight) returns +/// [`Error::WitnessTooLong`]: the funding fee was computed from the declared +/// bound, so a longer witness pays less than the agreed fee rate. +pub fn verify_funding_witness( + secp: &Secp256k1, + fund_tx: &Transaction, + input_index: usize, + input: &TxInputInfo, + prevouts: &[TxOut], + witness: &Witness, +) -> Result<(), Error> { + let invalid = |message: &str| Error::InvalidArgument(format!("input {input_index}: {message}")); + let tx_in = fund_tx + .input + .get(input_index) + .ok_or_else(|| invalid("the funding transaction has no such input"))?; + if tx_in.previous_output != input.outpoint { + return Err(invalid("the funding input spends a different outpoint")); + } + if prevouts.len() != fund_tx.input.len() { + return Err(invalid( + "expected one previous output per funding transaction input", + )); + } + if witness.size() > input.max_witness_len { + return Err(Error::WitnessTooLong { + witness_len: witness.size(), + max_witness_len: input.max_witness_len, + }); + } + let prevout = &prevouts[input_index]; + let elements: Vec<&[u8]> = witness.iter().collect(); + + let program = if input.redeem_script.is_empty() { + &prevout.script_pubkey + } else if input.redeem_script.to_p2sh() == prevout.script_pubkey { + &input.redeem_script + } else { + return Err(invalid( + "the redeem script does not hash to the previous output", + )); + }; + // More than one element on a Taproot output is a script-path spend (or + // carries an annex), which is not checked here. + let taproot_key_path = + program.is_p2tr() && input.redeem_script.is_empty() && elements.len() <= 1; + if !program.is_p2wpkh() && !taproot_key_path { + return Err(Error::UnsupportedScriptType); + } + if tx_in.script_sig != util::redeem_script_to_script_sig(&input.redeem_script)? { + return Err(invalid( + "the script signature must be empty for a native SegWit coin and push the redeem \ + script for a wrapped one", + )); + } + + if taproot_key_path { + let [element] = elements[..] else { + return Err(invalid("a Taproot key-path witness is a single signature")); + }; + let signature = bitcoin::taproot::Signature::from_slice(element) + .map_err(|e| invalid(&format!("not a Taproot signature: {e}")))?; + // `from_slice` reads a trailing 0x00 as the default sighash type, but + // BIP-341 makes a 65-byte signature with hash type 0x00 invalid: the + // default is only ever the 64-byte form. + if signature.sighash_type == TapSighashType::Default && element.len() != 64 { + return Err(invalid( + "a 65-byte Taproot signature must not use hash type 0x00", + )); + } + if !matches!( + signature.sighash_type, + TapSighashType::Default | TapSighashType::All + ) { + return Err(invalid(&format!( + "signature uses {}, SIGHASH_ALL is required", + signature.sighash_type + ))); + } + let output_key = XOnlyPublicKey::from_slice(&program.as_bytes()[2..])?; + let sighash = SighashCache::new(fund_tx) + .taproot_key_spend_signature_hash( + input_index, + &Prevouts::All(prevouts), + signature.sighash_type, + ) + .map_err(|e| invalid(&e.to_string()))?; + secp.verify_schnorr( + &signature.signature, + &Message::from_digest(sighash.to_byte_array()), + &output_key, + )?; + return Ok(()); + } + + let [signature, public_key] = elements[..] else { + return Err(invalid("a P2WPKH witness is a signature and a public key")); + }; + let signature = util::sighash_all_signature(signature)?; + let public_key = bitcoin::PublicKey::from_slice(public_key) + .map_err(|e| invalid(&format!("not a public key: {e}")))?; + let key_hash = public_key + .wpubkey_hash() + .map_err(|e| invalid(&e.to_string()))?; + if ScriptBuf::new_p2wpkh(&key_hash) != *program { + return Err(invalid("the public key does not own the coin")); + } + let sighash = SighashCache::new(fund_tx).p2wpkh_signature_hash( + input_index, + program, + prevout.value, + EcdsaSighashType::All, + )?; + secp.verify_ecdsa( + &Message::from_digest(sighash.to_byte_array()), + &signature, + &public_key.inner, + )?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -2427,4 +2596,193 @@ mod tests { "Output should pay to the accepter's payout SPK" ); } + + #[test] + fn funding_witnesses_verify_for_each_supported_coin() { + use bitcoin::key::TapTweak; + + let secp = Secp256k1::new(); + let secret_key = |byte| SecretKey::from_slice(&[byte; 32]).unwrap(); + let p2wpkh = |secret_key: &SecretKey| { + let public_key = CompressedPublicKey(PublicKey::from_secret_key(&secp, secret_key)); + ScriptBuf::new_p2wpkh(&public_key.wpubkey_hash()) + }; + let (native, wrapped) = (secret_key(1), secret_key(2)); + let taproot = Keypair::from_secret_key(&secp, &secret_key(3)); + let wrapped_program = p2wpkh(&wrapped); + let prevouts = vec![ + TxOut { + value: Amount::from_sat(10_000), + script_pubkey: p2wpkh(&native), + }, + TxOut { + value: Amount::from_sat(20_000), + script_pubkey: wrapped_program.to_p2sh(), + }, + TxOut { + value: Amount::from_sat(30_000), + script_pubkey: ScriptBuf::new_p2tr(&secp, taproot.x_only_public_key().0, None), + }, + ]; + let inputs: Vec = (0..3u8) + .map(|i| TxInputInfo { + outpoint: OutPoint::new(Txid::from_byte_array([i + 1; 32]), 0), + max_witness_len: 108, + redeem_script: if i == 1 { + wrapped_program.clone() + } else { + ScriptBuf::new() + }, + serial_id: i as u64, + }) + .collect(); + let fund_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: inputs + .iter() + .map(|input| TxIn { + previous_output: input.outpoint, + script_sig: util::redeem_script_to_script_sig(&input.redeem_script).unwrap(), + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value: Amount::from_sat(55_000), + script_pubkey: ScriptBuf::new(), + }], + }; + let ecdsa_witness = |secret_key: &SecretKey, index: usize| { + util::get_witness_for_p2wpkh_input( + &secp, + secret_key, + &fund_tx, + index, + EcdsaSighashType::All, + prevouts[index].value, + ) + .unwrap() + }; + let taproot_witness = |sighash_type| { + let sighash = SighashCache::new(&fund_tx) + .taproot_key_spend_signature_hash(2, &Prevouts::All(&prevouts), sighash_type) + .unwrap(); + let signature = secp.sign_schnorr_no_aux_rand( + &Message::from_digest(sighash.to_byte_array()), + &taproot.tap_tweak(&secp, None).to_keypair(), + ); + let signature = bitcoin::taproot::Signature { + signature, + sighash_type, + }; + Witness::from_slice(&[signature.to_vec()]) + }; + let verify = |tx: &Transaction, index: usize, prevouts: &[TxOut], witness: &Witness| { + verify_funding_witness(&secp, tx, index, &inputs[index], prevouts, witness) + }; + + verify(&fund_tx, 0, &prevouts, &ecdsa_witness(&native, 0)).unwrap(); + verify(&fund_tx, 1, &prevouts, &ecdsa_witness(&wrapped, 1)).unwrap(); + verify( + &fund_tx, + 2, + &prevouts, + &taproot_witness(TapSighashType::Default), + ) + .unwrap(); + verify( + &fund_tx, + 2, + &prevouts, + &taproot_witness(TapSighashType::All), + ) + .unwrap(); + + // The default sighash type is the 64-byte signature. The same + // signature with an explicit 0x00 hash byte verifies against the same + // digest, but BIP-341 rejects it. + let default_witness = taproot_witness(TapSighashType::Default); + let default_signature = default_witness.nth(0).unwrap(); + assert_eq!(default_signature.len(), 64); + let mut with_zero_byte = default_signature.to_vec(); + with_zero_byte.push(0x00); + assert!(matches!( + verify( + &fund_tx, + 2, + &prevouts, + &Witness::from_slice(&[with_zero_byte]) + ), + Err(Error::InvalidArgument(_)) + )); + + // A Taproot signature that commits to less than the whole transaction + // is valid on chain but would let the coin move into another one. + assert!(matches!( + verify( + &fund_tx, + 2, + &prevouts, + &taproot_witness(TapSighashType::None) + ), + Err(Error::InvalidArgument(_)) + )); + + // The SegWit signature hash does not cover script signatures, so the + // same witnesses stay valid signatures while the spend itself breaks: + // a wrapped coin without, or with the wrong, redeem script push, and a + // native coin with any script signature at all. + let with_script_sig = |index: usize, script_sig: ScriptBuf| { + let mut tx = fund_tx.clone(); + tx.input[index].script_sig = script_sig; + tx + }; + let wrong_push = util::redeem_script_to_script_sig(&p2wpkh(&native)).unwrap(); + for (index, script_sig, witness) in [ + (1, ScriptBuf::new(), ecdsa_witness(&wrapped, 1)), + (1, wrong_push.clone(), ecdsa_witness(&wrapped, 1)), + (0, wrong_push.clone(), ecdsa_witness(&native, 0)), + (2, wrong_push, taproot_witness(TapSighashType::Default)), + ] { + assert!(matches!( + verify( + &with_script_sig(index, script_sig), + index, + &prevouts, + &witness + ), + Err(Error::InvalidArgument(_)) + )); + } + + // A valid witness one byte over the size its input declared pays less + // than the fee rate the funding fee was computed for. + let witness = ecdsa_witness(&native, 0); + let mut short_bound = inputs[0].clone(); + short_bound.max_witness_len = witness.size() - 1; + assert!(matches!( + verify_funding_witness(&secp, &fund_tx, 0, &short_bound, &prevouts, &witness), + Err(Error::WitnessTooLong { witness_len, max_witness_len }) + if witness_len == witness.size() && max_witness_len == witness.size() - 1 + )); + + // A coin whose script cannot be checked is reported as such: neither + // valid nor invalid. A Taproot script-path spend is one of them. + let mut unsupported = prevouts.clone(); + unsupported[0].script_pubkey = + ScriptBuf::new_p2wsh(&bitcoin::WScriptHash::from_byte_array([9; 32])); + assert!(matches!( + verify(&fund_tx, 0, &unsupported, &ecdsa_witness(&native, 0)), + Err(Error::UnsupportedScriptType) + )); + assert!(matches!( + verify( + &fund_tx, + 2, + &prevouts, + &Witness::from_slice(&[vec![0x51], vec![0xc0; 33]]) + ), + Err(Error::UnsupportedScriptType) + )); + } } diff --git a/dlc/src/util.rs b/dlc/src/util.rs index 17e248db..a7b12cb3 100644 --- a/dlc/src/util.rs +++ b/dlc/src/util.rs @@ -173,37 +173,62 @@ pub fn sign_multi_sig_input( input_value: Amount, input_index: usize, ) -> Result<(), Error> { - let own_sig = get_sig_for_tx_input( + let own_sig = get_raw_sig_for_tx_input( secp, transaction, input_index, script_pubkey, input_value, - EcdsaSighashType::All, sk, )?; + let own_pk = PublicKey::from_secret_key(secp, sk); + transaction.input[input_index].witness = + multisig_witness(script_pubkey, (own_pk, own_sig), (*other_pk, *other_sig)); + Ok(()) +} - let own_pk = &PublicKey::from_secret_key(secp, sk); - - let other_finalized_sig = finalize_sig(other_sig, EcdsaSighashType::All); - - transaction.input[input_index].witness = if own_pk < other_pk { - Witness::from_slice(&[ - Vec::new(), - own_sig, - other_finalized_sig, - script_pubkey.to_bytes(), - ]) - } else { - Witness::from_slice(&[ - Vec::new(), - other_finalized_sig, - own_sig, - script_pubkey.to_bytes(), - ]) - }; +/// Builds the witness that spends a 2-of-2 multisig output, such as a DLC +/// funding output, from both parties' signatures. +/// +/// `OP_CHECKMULTISIG` expects the signatures in the order of the keys in +/// `script`, and [`make_funding_redeemscript`](crate::make_funding_redeemscript) +/// orders the keys ascending, so the signatures are placed by ascending public +/// key whichever order the two pairs are passed in. Every DLC spend of a +/// funding output commits to the whole transaction, so both signatures are +/// encoded with `SIGHASH_ALL`. +pub fn multisig_witness( + script: &Script, + a: (PublicKey, Signature), + b: (PublicKey, Signature), +) -> Witness { + let ((_, first), (_, second)) = if a.0 <= b.0 { (a, b) } else { (b, a) }; + Witness::from_slice(&[ + Vec::new(), + finalize_sig(&first, EcdsaSighashType::All), + finalize_sig(&second, EcdsaSighashType::All), + script.to_bytes(), + ]) +} - Ok(()) +/// Parses a signature witness element, a DER signature followed by its +/// sighash byte, and requires that byte to be `SIGHASH_ALL`. +/// +/// DDK checks signatures against the `SIGHASH_ALL` digest. An element with any +/// other sighash byte would pass that check here and then fail on chain, or +/// commit to less than the whole transaction. +pub(crate) fn sighash_all_signature(element: &[u8]) -> Result { + let signature = bitcoin::ecdsa::Signature::from_slice(element).map_err(|e| { + Error::InvalidArgument(format!( + "not a DER signature followed by a sighash byte: {e}" + )) + })?; + if signature.sighash_type != EcdsaSighashType::All { + return Err(Error::InvalidArgument(format!( + "signature uses {}, SIGHASH_ALL is required", + signature.sighash_type + ))); + } + Ok(signature.signature) } /// Transforms a redeem script for a p2sh-p2w* output to a script signature. @@ -275,6 +300,27 @@ mod tests { use bitcoin::hashes::Hash; use bitcoin::WPubkeyHash; + #[test] + fn multisig_witness_orders_signatures_by_public_key() { + let secp = Secp256k1::new(); + let [a, b] = [1u8, 2].map(|byte| { + let secret_key = SecretKey::from_slice(&[byte; 32]).unwrap(); + ( + PublicKey::from_secret_key(&secp, &secret_key), + secp.sign_ecdsa(&Message::from_digest([7; 32]), &secret_key), + ) + }); + let script = crate::make_funding_redeemscript(&a.0, &b.0); + + let witness = multisig_witness(&script, a, b); + assert_eq!(witness, multisig_witness(&script, b, a)); + let lower = if a.0 < b.0 { a } else { b }; + assert_eq!( + witness.nth(1).unwrap(), + finalize_sig(&lower.1, EcdsaSighashType::All).as_slice() + ); + } + #[test] fn empty_redeem_script_gives_an_empty_script_sig() { let script_sig = redeem_script_to_script_sig(&ScriptBuf::new()).unwrap();