Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
4e61580
add a p2pk signature verifier
mariocynicys Apr 7, 2025
15f867f
simplify the parameters of verify_p2pk_input_pubkey
mariocynicys Apr 7, 2025
d638bc9
add test for p2pk detection function
mariocynicys Apr 7, 2025
6f24451
add a test for verify_p2pk_input_pubkey
mariocynicys Apr 7, 2025
a89e101
add a test for verify_p2pk_input_pubkey (33-byte pubkeys)
mariocynicys Apr 8, 2025
1f0e0f3
add more details to the signature version fixme
mariocynicys Apr 8, 2025
d127ade
fix compilation error
mariocynicys Apr 8, 2025
2ce3c97
add to_secp256k1_pubkey() to Public
mariocynicys Apr 8, 2025
d1b2e4e
adjust verify_p2pk_input_pubkey() to account for both pubkey formats
mariocynicys Apr 8, 2025
a97afb4
simplify does_script_spend_p2pk
mariocynicys Apr 9, 2025
8a9923a
review(sami): resolve a couple of comments - readability, DRY, cleanness
mariocynicys Apr 16, 2025
ba82af5
import the correct Deref
mariocynicys Apr 16, 2025
b262954
review(dimxy): fix typo in 'signature'
mariocynicys Apr 17, 2025
3f6ce9b
review(dimxy): utilize the match for the successfulness check
mariocynicys Apr 17, 2025
b161cd4
review(dimxy): add a doc comment for `check_all_utxo_inputs_signed_by…
mariocynicys Apr 17, 2025
715f504
don't list (and thus spend) p2pk outputs for segwit coins
mariocynicys Apr 21, 2025
3e0bb9a
Merge remote-tracking branch 'origin/dev' into p2pk-spends-in-swaps
mariocynicys Apr 24, 2025
bdb3d05
fix utxo inputs sig check doc comment
mariocynicys May 20, 2025
f435492
be more idomatic with matches
mariocynicys May 20, 2025
f33ff36
Merge remote-tracking branch 'origin/dev' into p2pk-spends-in-swaps
mariocynicys May 21, 2025
04dfbf4
get the amount from rpc for overwintered txs
mariocynicys May 21, 2025
75eabfa
add a test for overwintered txs p2pk input verification
mariocynicys May 21, 2025
8fdd7d3
review(dimxy): just query for the prev_output and check it against th…
mariocynicys May 21, 2025
86b0949
Revert "review(dimxy): just query for the prev_output and check it ag…
mariocynicys May 23, 2025
95411c0
use get_transaction_bytes instead of get_versbose_transaction
mariocynicys May 23, 2025
72d6754
review(onur): inline a small frequently used method
mariocynicys May 27, 2025
cffe6c0
review(onur): reorder if conditions for readability - make the p2pk f…
mariocynicys May 27, 2025
440f1e7
review(onur): move scriptsig utitlity method to impl Script
mariocynicys May 27, 2025
0a84be1
review(onur): separate prev tx fetching and amount setting logic in p…
mariocynicys May 27, 2025
a2c8f8f
review(onur): add a pre-check to exit early if the inputs are empty
mariocynicys May 27, 2025
77aa1b1
lazily calculate `unsigned_tx` only when needed
mariocynicys May 27, 2025
e3d1edb
Revert "review(onur): add a pre-check to exit early if the inputs are…
mariocynicys May 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion mm2src/coins/qrc20.rs
Original file line number Diff line number Diff line change
Expand Up @@ -847,7 +847,8 @@ impl SwapOps for Qrc20Coin {
},
};
let fee_tx_hash = fee_tx.hash().reversed().into();
let inputs_signed_by_pub = check_all_utxo_inputs_signed_by_pub(fee_tx, validate_fee_args.expected_sender)?;
let inputs_signed_by_pub =
check_all_utxo_inputs_signed_by_pub(self, fee_tx, validate_fee_args.expected_sender)?;
if !inputs_signed_by_pub {
return MmError::err(ValidatePaymentError::WrongPaymentTx(
"The dex fee was sent from wrong address".to_string(),
Expand Down
169 changes: 155 additions & 14 deletions mm2src/coins/utxo/utxo_common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2019,19 +2019,99 @@ pub async fn send_maker_refunds_payment<T: UtxoCommonOps + SwapOps>(
refund_htlc_payment(coin, args).await.map(|tx| tx.into())
}

/// Extracts pubkey from script sig
fn pubkey_from_script_sig(script: &Script) -> Result<H264, String> {
/// Extracts the signature from a scriptSig at instruction 0.
///
/// Usable for P2PK and P2PKH scripts.
fn extract_signature(script: &Script) -> Result<Vec<u8>, String> {
match script.get_instruction(0) {
Some(Ok(instruction)) => match instruction.opcode {
Opcode::OP_PUSHBYTES_70 | Opcode::OP_PUSHBYTES_71 | Opcode::OP_PUSHBYTES_72 => match instruction.data {
Some(bytes) => try_s!(SecpSignature::from_der(&bytes[..bytes.len() - 1])),
None => return ERR!("No data at instruction 0 of script {:?}", script),
Some(bytes) => Ok(bytes.to_vec()),
None => ERR!("No data at instruction 0 of script {:?}", script),
},
_ => return ERR!("Unexpected opcode {:?}", instruction.opcode),
_ => ERR!("Unexpected opcode {:?}", instruction.opcode),
},
Some(Err(e)) => return ERR!("Error {} on getting instruction 0 of script {:?}", e, script),
None => return ERR!("None instruction 0 of script {:?}", script),
Some(Err(e)) => ERR!("Error {} on getting instruction 0 of script {:?}", e, script),
None => ERR!("None instruction 0 of script {:?}", script),
}
}

/// Checks if a scriptSig is a script that spends a P2PK output.
fn does_script_spend_p2pk(script: &Script) -> bool {
// P2PK scriptSig is just a single signature. The script should consist of a single push bytes
// instruction with the data as the signature.
extract_signature(script).is_ok() && script.get_instruction(1).is_none()
}

/// Verifies that the script that spends a P2PK is signed by the expected pubkey.
fn verify_p2pk_input_pubkey(
script: &Script,
expected_pubkey: &Public,
unsigned_tx: &TransactionInputSigner,
index: usize,
signature_version: SignatureVersion,
fork_id: u32,
) -> Result<bool, String> {
// Extract the signature from the scriptSig.
let signature = extract_signature(script)?;
// Validate the signature.
try_s!(SecpSignature::from_der(&signature[..signature.len() - 1]));
let signature = signature.into();
// Make sure we have no more instructions. P2PK scriptSigs consist of a single instruction only containing the signature.
if script.get_instruction(1).is_some() {
return ERR!("Unexpected instruction at position 2 of script {:?}", script);
};
// Get the scriptPub for this input. We need it to get the transaction sig_hash to sign (but actually "to verify" in this case).
let pubkey = expected_pubkey
.to_secp256k1_pubkey()
.map_err(|e| ERRL!("Error converting plain pubkey to secp256k1 pubkey: {}", e))?;
// P2PK scriptPub has two valid possible formats depending on whether the public key is written in compressed or uncompressed form.
let possible_pubkey_scripts = [
Builder::build_p2pk(&Public::Compressed(pubkey.serialize().into())),
Builder::build_p2pk(&Public::Normal(pubkey.serialize_uncompressed().into())),
];
for pubkey_script in possible_pubkey_scripts {
// Get the transaction hash that has been signed in the scriptSig.
let hash = match signature_hash_to_sign(
unsigned_tx,
index,
&pubkey_script,
// FIXME: But P2PK scripts never use segwit as signature version. Should we hardcode this to SignatureVersion::Base or ::ForkId?
// UPD: I think we can safely remove this fixme if the following reasoning holds:
// Since we are using p2pk here, this means the coin isn't segwit. right? but it's the coin supplied from the caller anyways
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, KDF does not mix segwit and non segwit inputs but another app could.
(here we actually check txns created by a remote app)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

upon digging down, looks like we list p2pk utxos for segwit address:
https://github.com/KomodoPlatform/komodo-defi-framework/blob/b161cd44aff8ea5f140018c2589ee90d00952454/mm2src/coins/utxo/rpc_clients/electrum_rpc/client.rs#L805-L809

we should check address.address_format.is_segwit() and not add p2pk outputs if so. i.e. we should associate p2pk outputs only with coins enabled in non-segwit mode, i.e. coins enabled for p2pkh outputs.

another alternative is to change signature_version passed to signature_hash_to_sign for each input based on its utxo type (p.s. actually this will look inconsistent; raising the question why is a coin labeled as segwit but it can spend other address types)

Copy link
Collaborator

Choose a reason for hiding this comment

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

we should check address.address_format.is_segwit() and not add p2pk outputs

maybe just do not allow to add a pubkey to segwit addr

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

715f504 done here

// inside `check_all_utxo_inputs_signed_by_pub` and we can use the signature version it uses (which isn't segwit for sure).
// But: why is it true that a segwit coin will never produce p2pk spends? (check with_key_pair.rs - sign_tx(), it doesn't reject signing a p2pk input)
signature_version,
SIGHASH_ALL,
fork_id,
) {
Ok(hash) => hash,
Err(e) => return ERR!("Error calculating signature hash: {}", e),
};
// Verify that the signature is valid for the transaction hash with respect to the expected public key.
return match expected_pubkey.verify(&hash, &signature) {
Ok(is_successful) => {
if is_successful {
Ok(true)
} else {
// If the verification isn't successful, try the other possible pubkey script.
continue;
}
},
Err(e) => ERR!("Error verifying signature: {}", e),
};
}

// Both possible pubkey scripts failed to verify the signature.
Ok(false)
}

/// Extracts pubkey from script sig
fn pubkey_from_script_sig(script: &Script) -> Result<H264, String> {
// Extract the signature from the scriptSig.
let signature = extract_signature(script)?;
// Validate the signature.
try_s!(SecpSignature::from_der(&signature[..signature.len() - 1]));

let pubkey = match script.get_instruction(1) {
Some(Ok(instruction)) => match instruction.opcode {
Expand Down Expand Up @@ -2087,18 +2167,44 @@ where
}
}

pub fn check_all_utxo_inputs_signed_by_pub(
pub fn check_all_utxo_inputs_signed_by_pub<T: UtxoCommonOps>(
coin: &T,
tx: &UtxoTx,
expected_pub: &[u8],
) -> Result<bool, MmError<ValidatePaymentError>> {
for input in &tx.inputs {
let expected_pub =
H264::from_slice(expected_pub).map_to_mm(|e| ValidatePaymentError::TxDeserializationError(e.to_string()))?;
let mut unsigned_tx: TransactionInputSigner = tx.clone().into();
unsigned_tx.consensus_branch_id = coin.as_ref().conf.consensus_branch_id;

for (idx, input) in tx.inputs.iter().enumerate() {
let script = Script::from(input.script_sig.clone());
let pubkey = if input.has_witness() {
// Extract the pubkey from a P2WPKH scriptSig.
pubkey_from_witness_script(&input.script_witness).map_to_mm(ValidatePaymentError::TxDeserializationError)?
} else if does_script_spend_p2pk(&script) {
// For P2PK scriptsSigs, verfiy that the signature corresponds to the expected public key.
let successful_verification = verify_p2pk_input_pubkey(
&script,
&Public::Compressed(expected_pub),
// FIXME: For overwintered txs, we also need to set the input amount as it's used in sighash calcuations!
&unsigned_tx,
idx,
coin.as_ref().conf.signature_version,
coin.as_ref().conf.fork_id,
)
.map_to_mm(ValidatePaymentError::TxDeserializationError)?;
if successful_verification {
// No pubkey extraction for P2PK inputs. Continue.
continue;
}
return Ok(false);
} else {
let script: Script = input.script_sig.clone().into();
// Extract the pubkey from a P2PKH scriptSig.
pubkey_from_script_sig(&script).map_to_mm(ValidatePaymentError::TxDeserializationError)?
};
if *pubkey != expected_pub {

if pubkey != expected_pub {
return Ok(false);
}
}
Expand Down Expand Up @@ -2146,7 +2252,7 @@ pub fn watcher_validate_taker_fee<T: UtxoCommonOps + SwapOps>(
};

let taker_fee_tx: UtxoTx = deserialize(tx_from_rpc.hex.0.as_slice())?;
let inputs_signed_by_pub = check_all_utxo_inputs_signed_by_pub(&taker_fee_tx, &sender_pubkey)?;
let inputs_signed_by_pub = check_all_utxo_inputs_signed_by_pub(&coin, &taker_fee_tx, &sender_pubkey)?;
if !inputs_signed_by_pub {
return MmError::err(ValidatePaymentError::WrongPaymentTx(format!(
"{}: Taker fee does not belong to the verified public key",
Expand Down Expand Up @@ -2278,7 +2384,7 @@ pub fn validate_fee<T: UtxoCommonOps + SwapOps>(
) -> ValidatePaymentFut<()> {
let dex_address = try_f!(dex_address(&coin).map_to_mm(ValidatePaymentError::InternalError));
let burn_address = try_f!(burn_address(&coin).map_to_mm(ValidatePaymentError::InternalError));
let inputs_signed_by_pub = try_f!(check_all_utxo_inputs_signed_by_pub(&tx, sender_pubkey));
let inputs_signed_by_pub = try_f!(check_all_utxo_inputs_signed_by_pub(&coin, &tx, sender_pubkey));
if !inputs_signed_by_pub {
return Box::new(futures01::future::err(
ValidatePaymentError::WrongPaymentTx(format!(
Expand Down Expand Up @@ -2394,7 +2500,7 @@ pub fn watcher_validate_taker_payment<T: UtxoCommonOps + SwapOps>(
let coin = coin.clone();

let fut = async move {
let inputs_signed_by_pub = check_all_utxo_inputs_signed_by_pub(&taker_payment_tx, &input.taker_pub)?;
let inputs_signed_by_pub = check_all_utxo_inputs_signed_by_pub(&coin, &taker_payment_tx, &input.taker_pub)?;
if !inputs_signed_by_pub {
return MmError::err(ValidatePaymentError::WrongPaymentTx(format!(
"{INVALID_SENDER_ERR_LOG}: Taker payment does not belong to the verified public key"
Expand Down Expand Up @@ -5292,6 +5398,41 @@ fn test_pubkey_from_script_sig() {
pubkey_from_script_sig(&script_sig_err).unwrap_err();
}

#[test]
fn test_does_script_spend_p2pk() {
let script_sig = Script::from("473044022071edae37cf518e98db3f7637b9073a7a980b957b0c7b871415dbb4898ec3ebdc022031b402a6b98e64ffdf752266449ca979a9f70144dba77ed7a6a25bfab11648f6012103ad6f89abc2e5beaa8a3ac28e22170659b3209fe2ddf439681b4b8f31508c36fa");
assert!(!does_script_spend_p2pk(&script_sig));
// The scriptSig of the input spent from: https://mempool.space/tx/1db6251a9afce7025a2061a19e63c700dffc3bec368bd1883decfac353357a9d
let script_sig = Script::from("483045022078e86c021003cca23842d4b2862dfdb68d2478a98c08c10dcdffa060e55c72be022100f6a41da12cdc2e350045f4c97feeab76a7c0ab937bd8a9e507293ce6d37c9cc201");
assert!(does_script_spend_p2pk(&script_sig));
}

#[test]
fn test_verify_p2pk_input_pubkey() {
// 65-byte (uncompressed) pubkey example.
// https://mempool.space/tx/1db6251a9afce7025a2061a19e63c700dffc3bec368bd1883decfac353357a9d
let tx: UtxoTx = "0100000001740443e82e526cef440ed590d1c43a67f509424134542de092e5ae68721575d60100000049483045022078e86c021003cca23842d4b2862dfdb68d2478a98c08c10dcdffa060e55c72be022100f6a41da12cdc2e350045f4c97feeab76a7c0ab937bd8a9e507293ce6d37c9cc201ffffffff0200f2052a010000001976a91431891996d28cc0214faa3760a765b40846bd035888ac00ba1dd2050000004341049464205950188c29d377eebca6535e0f3699ce4069ecd77ffebfbd0bcf95e3c134cb7d2742d800a12df41413a09ef87a80516353a2f0a280547bb5512dc03da8ac00000000".into();
let script_sig = tx.inputs[0].script_sig.clone().into();
let expected_pub = Public::Normal("049464205950188c29d377eebca6535e0f3699ce4069ecd77ffebfbd0bcf95e3c134cb7d2742d800a12df41413a09ef87a80516353a2f0a280547bb5512dc03da8".into());
let unsigned_tx: TransactionInputSigner = tx.into();
let successful_verification =
verify_p2pk_input_pubkey(&script_sig, &expected_pub, &unsigned_tx, 0, SignatureVersion::Base, 0).unwrap();
assert!(successful_verification);

// 33-byte (compressed) pubkey example.
// https://kmdexplorer.io/tx/07ceb50f9eedc3b820e48dc1e5250f6625115afe4ace3089bfcc66b34f5d4344
let tx: UtxoTx = "0400008085202f89013683897bf3bfb1e217663aa9591bd73c9eb105f8c8471e88dbe7152ca7627a19050000004948304502210087100bf4a665ebab3cc6d3472068905bdc6c6def37e432597e78e2ccc4da017a02205b5f0800cabe84bc49b5eb0997926b48dfee3b8ca5a31623ae9506272f8a5cd501ffffffff0288130000000000002321020e46e79a2a8d12b9b5d12c7a91adb4e454edfae43c0a0cb805427d2ac7613fd9ac0000000000000000226a20976bd7ad5596ac3521fd90295e753b1096e4eb90ad9ded1170b2ed81f810df5fc0dbf36752ea42000000000000000000000000".into();
let script_sig = tx.inputs[0].script_sig.clone().into();
let expected_pub = Public::Compressed("02f9a7b49282885cd03969f1f5478287497bc8edfceee9eac676053c107c5fcdaf".into());
let mut unsigned_tx: TransactionInputSigner = tx.into();
// For overwintered transactions, the amount must be set, as wel as the consensus branch id.
unsigned_tx.inputs[0].amount = 10000;
unsigned_tx.consensus_branch_id = 0x76b8_09bb;
let successful_verification =
verify_p2pk_input_pubkey(&script_sig, &expected_pub, &unsigned_tx, 0, SignatureVersion::Base, 0).unwrap();
assert!(successful_verification);
}

#[test]
fn test_tx_v_size() {
// Multiple legacy inputs with P2SH and P2PKH output
Expand Down
8 changes: 5 additions & 3 deletions mm2src/mm2_bitcoin/keys/src/public.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ use crypto::dhash160;
use hash::{H160, H264, H520};
use hex::ToHex;
use secp256k1::{recovery::{RecoverableSignature, RecoveryId},
Message as SecpMessage, PublicKey, Signature as SecpSignature};
use std::{fmt, ops};
Error as SecpError, Message as SecpMessage, PublicKey, Signature as SecpSignature};
use std::{fmt, ops::Deref};
use {CompactSignature, Error, Message, Signature};

/// Secret public key
Expand Down Expand Up @@ -80,9 +80,11 @@ impl Public {
Public::Normal(_) => None,
}
}

pub fn to_secp256k1_pubkey(&self) -> Result<PublicKey, SecpError> { PublicKey::from_slice(self.deref()) }
}

impl ops::Deref for Public {
impl Deref for Public {
type Target = [u8];

fn deref(&self) -> &Self::Target {
Expand Down
Loading