Merge rust-bitcoin/rust-bitcoin#6755: crypto: Extend `EcdsaSighashType` to non-standard values
What changed, and why it matters
This change fixes a bug where the library rejected certain unusual but Bitcoin-network-valid signature hash types. Before the fix, users could not verify signatures from real mainnet transactions that use these non-standard values, which could cause valid transactions to be incorrectly rejected. The patch extends the type system to accept any valid value while still treating the standard ones normally.
Review downstream callers that previously relied on EcdsaSighashType only containing standard values, especially any code that exhaustively matches the enum or assumes From<EcdsaSighashType> for TapSighashType. Ensure the new TryFrom is handled. Consider whether any APIs that previously rejected non-standard sighash types should now accept them or remain restrictive.
Security signals we found
Fixes inability to verify consensus-valid signatures with non-standard sighash types
Changes signature parsing from standard-only to consensus-accepting
Adds NonStandard(u32) variant preserving full u32 value for sighash computation
Adds regression test against real mainnet transaction
Converts Ecdsa->Tap conversion to fallible to prevent invalid Taproot sighash types
Evidence from the diff
The commit extends EcdsaSighashType to carry a NonStandard(u32) variant, changes Signature::from_slice to use from_consensus instead of from_standard, adds to_consensus_u8/to_u32 round-tripping, updates SplitAnyoneCanPay and is_single to mask non-standard values correctly, and adds a regression test using mainnet tx 969c4f…d50d (sighash byte 0x65). It also converts the EcdsaSighashType -> TapSighashType conversion to TryFrom because Taproot does not accept non-standard ECDSA sighash types.
Changed components
crypto/src/sighash.rscrypto/src/ecdsa.rsbitcoin/src/crypto/sighash.rsInspect captured patch +179 / −41
### bitcoin/src/crypto/sighash.rs
@@ -308,6 +308,16 @@ impl SplitAnyoneCanPay for EcdsaSighashType {
AllPlusAnyoneCanPay => (All, true),
NonePlusAnyoneCanPay => (None, true),
SinglePlusAnyoneCanPay => (Single, true),
+ NonStandard(n) => {
+ // Check sighash tyoe
+ let sighash_type = match n & 0x1f {
+ 0x02 => None,
+ 0x03 => Single,
+ _ => All,
+ };
+ // Check ACP
+ (sighash_type, n & 0x80 == 0x80)
+ }
}
}
}
@@ -1909,6 +1919,43 @@ mod tests {
);
}
+ #[test]
+ fn mainnet_input_with_non_standard_sighash_type_verifies() {
+ use secp256k1::PublicKey;
+
+ // https://github.com/rust-bitcoin/rust-bitcoin/issues/6647
+ //
+ // Mainnet tx 969c4f116f0a68406d30dc80bf17991fb8fe7fa1b240382baefa2c324b79d50d
+ // (block 508011), a P2SH-P2WPKH input whose signature uses sighash type byte 0x65.
+ let tx = decode_from_slice::<Transaction>(&hex!(
+ "01000000000101447e208868dbc8e930fc6eba4fe0d0abfe0d9dc2db4ba70542e02467f00205c90\
+ 100000017160014e20c60563894174c253ae937ba59ace46ab9ffb1ffffffff010845f30500000000\
+ 1976a91414ac7fc2a782bde1555b753d75ff4ed146683cae88ac024730440220120003c32cca7eabf\
+ 07bad5c31125accc09d13c39546fa93833b8b69a2c72ed7022057083dc2ed348156874b8af859ac7a\
+ 9c16e5ce39353f3f1ac2226b49c2b319af652103f73386ac6e567581f8d0611ad7a8536c3cd0253e5\
+ 35f6fc4707514b2ab54198700000000"
+ ))
+ .unwrap();
+
+ let witness = &tx.inputs[0].witness;
+ let sig = crate::ecdsa::Signature::from_slice(witness.get(0).unwrap())
+ .expect("non-standard sighash types parse");
+ assert_eq!(sig.sighash_type, EcdsaSighashType::NonStandard(0x65));
+ let pk = PublicKey::from_slice(witness.get(1).unwrap()).unwrap();
+
+ // redeemScript from the scriptSig: the v0 witness program.
+ let redeem = ScriptPubKeyBuf::from_hex_no_length_prefix(
+ "0014e20c60563894174c253ae937ba59ace46ab9ffb1",
+ )
+ .unwrap();
+ let amount = Amount::from_sat_u32(99_830_000);
+
+ let sighash = SighashCache::new(&tx)
+ .p2wpkh_signature_hash(0, &redeem, amount, sig.sighash_type)
+ .unwrap();
+
+ secp256k1::ecdsa::verify(&sig.signature, sighash, &pk).expect("consensus digest verifies");
+ }
#[test]
fn bip143_p2wpkh_nested_in_p2sh() {
let tx = decode_from_slice::<Transaction>(
### crypto/src/ecdsa.rs
@@ -56,16 +56,19 @@ impl Signature {
Self { signature, sighash_type: EcdsaSighashType::All }
}
- /// Deserializes from slice following the standardness rules for [`EcdsaSighashType`].
+ /// Deserializes from slice.
///
+ /// Non-standard sighash types are accepted here since they're consensus valid, so we use
+ /// [`EcdsaSighashType::from_consensus`] here instead of [`EcdsaSighashType::from_standard`]
+ /// to deserialize the sighash type.
+ ///
/// # Errors
///
/// * [`DecodeError::EmptySignature`] if the slice is empty.
/// * [`DecodeError::InvalidDer`] if the slice is not a valid DER encoding for an ECDSA signature.
pub fn from_slice(sl: &[u8]) -> Result<Self, DecodeError> {
let (sighash_type, sig) = sl.split_last().ok_or(DecodeError::EmptySignature)?;
- let sighash_type = EcdsaSighashType::from_standard(u32::from(*sighash_type))
- .map_err(DecodeError::SighashType)?;
+ let sighash_type = EcdsaSighashType::from_consensus(u32::from(*sighash_type));
let signature = secp256k1::ecdsa::Signature::from_der(sig)
.map_err(|_| DecodeError::InvalidDer(InvalidDerError))?;
Ok(Self { signature, sighash_type })
@@ -79,7 +82,7 @@ impl Signature {
let mut buf = [0u8; MAX_SIG_LEN];
let signature = self.signature.serialize_der();
buf[..signature.len()].copy_from_slice(&signature);
- buf[signature.len()] = self.sighash_type as u8;
+ buf[signature.len()] = self.sighash_type.to_consensus_u8();
SerializedSignature { data: buf, len: signature.len() + 1 }
}
@@ -93,7 +96,7 @@ impl Signature {
.serialize_der()
.iter()
.copied()
- .chain(iter::once(self.sighash_type as u8))
+ .chain(iter::once(self.sighash_type.to_consensus_u8()))
.collect()
}
}
### crypto/src/sighash.rs
@@ -99,30 +99,58 @@ impl TapSighashType {
}
/// Hashtype of an input's signature, encoded in the last byte of the signature.
-///
-/// Fixed values so they can be cast as integer types for encoding (see also
-/// [`TapSighashType`]).
-#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
+#[derive(Copy, Clone, Debug)]
pub enum EcdsaSighashType {
/// 0x1: Sign all outputs.
- All = 0x01,
+ All,
/// 0x2: Sign no outputs --- anyone can choose the destination.
- None = 0x02,
+ None,
/// 0x3: Sign the output whose index matches this input's index. If none exists,
/// sign the hash `0000000000000000000000000000000000000000000000000000000000000001`.
/// (This rule is probably an unintentional C++ism, but it's consensus so we have
/// to follow it.)
- Single = 0x03,
+ Single,
/// 0x81: Sign all outputs but only this input.
- AllPlusAnyoneCanPay = 0x81,
+ AllPlusAnyoneCanPay,
/// 0x82: Sign no outputs and only this input.
- NonePlusAnyoneCanPay = 0x82,
+ NonePlusAnyoneCanPay,
/// 0x83: Sign one output and only this input (see `Single` for what "one output" means).
- SinglePlusAnyoneCanPay = 0x83,
+ SinglePlusAnyoneCanPay,
+ /// Any other value: consensus-valid but non-standard
+ ///
+ /// The value is a `u32` because the legacy and segwit v0 signing algorithms hash the
+ /// sighash type as 4 bytes, even though only the lowest byte is appended to the
+ /// signature in a transaction. On bitcoin the higher bits are always zero, but on replay-protected forks they are
+ /// sometimes set.
+ NonStandard(u32),
}
#[cfg(feature = "serde")]
internals::serde_string_impl!(EcdsaSighashType, "a EcdsaSighashType data");
+impl PartialEq for EcdsaSighashType {
+ #[inline]
+ fn eq(&self, other: &Self) -> bool { self.to_u32() == other.to_u32() }
+}
+
+impl Eq for EcdsaSighashType {}
+
+impl PartialOrd for EcdsaSighashType {
+ #[inline]
+ fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> { Some(self.cmp(other)) }
+}
+
+impl Ord for EcdsaSighashType {
+ #[inline]
+ fn cmp(&self, other: &Self) -> core::cmp::Ordering { self.to_u32().cmp(&other.to_u32()) }
+}
+
+impl core::hash::Hash for EcdsaSighashType {
+ #[inline]
+ fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
+ core::hash::Hash::hash(&self.to_u32(), state);
+ }
+}
+
impl fmt::Display for EcdsaSighashType {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -133,6 +161,7 @@ impl fmt::Display for EcdsaSighashType {
Self::AllPlusAnyoneCanPay => "SIGHASH_ALL|SIGHASH_ANYONECANPAY",
Self::NonePlusAnyoneCanPay => "SIGHASH_NONE|SIGHASH_ANYONECANPAY",
Self::SinglePlusAnyoneCanPay => "SIGHASH_SINGLE|SIGHASH_ANYONECANPAY",
+ Self::NonStandard(n) => return write!(f, "0x{:02x}", n),
};
f.write_str(s)
}
@@ -150,7 +179,17 @@ impl str::FromStr for EcdsaSighashType {
"SIGHASH_ALL|SIGHASH_ANYONECANPAY" => Ok(Self::AllPlusAnyoneCanPay),
"SIGHASH_NONE|SIGHASH_ANYONECANPAY" => Ok(Self::NonePlusAnyoneCanPay),
"SIGHASH_SINGLE|SIGHASH_ANYONECANPAY" => Ok(Self::SinglePlusAnyoneCanPay),
- _ => Err(SighashTypeParseError { unrecognized: s.into() }),
+ _ => {
+ // Non Standard values are displayed as "0xNN"
+ // We return `Self::from_consensus(n)` instead of `Self::NonStandard(n)` so that
+ // standard bytes map to the named variants (eg. 0x01 return `All`)
+ if let Some(hex) = s.strip_prefix("0x") {
+ if let Ok(n) = u32::from_str_radix(hex, 16) {
+ return Ok(Self::from_consensus(n));
+ }
+ }
+ Err(SighashTypeParseError { unrecognized: s.into() })
+ }
}
}
}
@@ -163,35 +202,32 @@ impl EcdsaSighashType {
///
/// See: <https://github.com/bitcoin/bitcoin/blob/e486597/src/script/interpreter.cpp#L1618-L1619>
#[inline]
- pub fn is_single(&self) -> bool { matches!(self, Self::Single | Self::SinglePlusAnyoneCanPay) }
+ pub fn is_single(&self) -> bool {
+ match *self {
+ Self::Single => true,
+ Self::SinglePlusAnyoneCanPay => true,
+ Self::NonStandard(n) => (n & 0x1f) == 0x03,
+ _ => false,
+ }
+ }
/// Constructs a new [`EcdsaSighashType`] from a raw `u32`.
///
+ /// This round-trips: `from_consensus(n).to_u32() == n` for every `n`.
+ ///
/// **Note**: this replicates consensus behavior, for current standardness rules correctness
/// you probably want [`Self::from_standard`].
- ///
- /// This might cause unexpected behavior because it does not roundtrip. That is,
- /// `EcdsaSighashType::from_consensus(n) as u32 != n` for non-standard values of `n`. While
- /// verifying signatures, the user should retain the `n` and use it to compute the signature hash
- /// message.
#[inline]
pub fn from_consensus(n: u32) -> Self {
- // In Bitcoin Core, the SignatureHash function will mask the (int32) value with
- // 0x1f to (apparently) deactivate ACP when checking for SINGLE and NONE bits.
- // We however want to be matching also against on ACP-masked ALL, SINGLE, and NONE.
- // So here we re-activate ACP.
- let mask = 0x1f | 0x80;
- match n & mask {
+ match n {
// "real" sighashes
0x01 => Self::All,
0x02 => Self::None,
0x03 => Self::Single,
0x81 => Self::AllPlusAnyoneCanPay,
0x82 => Self::NonePlusAnyoneCanPay,
0x83 => Self::SinglePlusAnyoneCanPay,
- // catchalls
- x if x & 0x80 == 0x80 => Self::AllPlusAnyoneCanPay,
- _ => Self::All,
+ other => Self::NonStandard(other),
}
}
@@ -215,22 +251,41 @@ impl EcdsaSighashType {
}
/// Converts [`EcdsaSighashType`] to a `u32` sighash flag.
+ #[inline]
+ pub fn to_u32(self) -> u32 {
+ match self {
+ Self::All => 0x01,
+ Self::None => 0x02,
+ Self::Single => 0x03,
+ Self::AllPlusAnyoneCanPay => 0x81,
+ Self::NonePlusAnyoneCanPay => 0x82,
+ Self::SinglePlusAnyoneCanPay => 0x83,
+ Self::NonStandard(n) => n,
+ }
+ }
+
+ /// Converts [`EcdsaSighashType`] to the `u8` appended to a signature.
///
- /// The returned value is guaranteed to be a valid according to standardness rules.
+ /// Only the lowest byte of the sighash type is appended to the signature, so we truncate
+ /// to the lowest 8 bits.
#[inline]
- pub fn to_u32(self) -> u32 { self as u32 }
+ pub fn to_consensus_u8(self) -> u8 { self.to_u32() as u8 }
}
-impl From<EcdsaSighashType> for TapSighashType {
+impl TryFrom<EcdsaSighashType> for TapSighashType {
+ type Error = InvalidSighashTypeError;
+
#[inline]
- fn from(s: EcdsaSighashType) -> Self {
+ fn try_from(s: EcdsaSighashType) -> Result<Self, Self::Error> {
match s {
- EcdsaSighashType::All => Self::All,
- EcdsaSighashType::None => Self::None,
- EcdsaSighashType::Single => Self::Single,
- EcdsaSighashType::AllPlusAnyoneCanPay => Self::AllPlusAnyoneCanPay,
- EcdsaSighashType::NonePlusAnyoneCanPay => Self::NonePlusAnyoneCanPay,
- EcdsaSighashType::SinglePlusAnyoneCanPay => Self::SinglePlusAnyoneCanPay,
+ EcdsaSighashType::All => Ok(Self::All),
+ EcdsaSighashType::None => Ok(Self::None),
+ EcdsaSighashType::Single => Ok(Self::Single),
+ EcdsaSighashType::AllPlusAnyoneCanPay => Ok(Self::AllPlusAnyoneCanPay),
+ EcdsaSighashType::NonePlusAnyoneCanPay => Ok(Self::NonePlusAnyoneCanPay),
+ EcdsaSighashType::SinglePlusAnyoneCanPay => Ok(Self::SinglePlusAnyoneCanPay),
+ // Taproot doesnt accept non-standard sighash
+ EcdsaSighashType::NonStandard(n) => Err(InvalidSighashTypeError(n)),
}
}
}
@@ -404,6 +459,39 @@ mod tests {
}
}
+ #[test]
+ fn ecdsa_sighash_type_from_consensus_roundtrips() {
+ use super::EcdsaSighashType;
+
+ for n in 0..=255u32 {
+ let ty = EcdsaSighashType::from_consensus(n);
+ assert_eq!(ty.to_u32(), n);
+ assert_eq!(u32::from(ty.to_consensus_u8()), n);
+ match n {
+ 0x01 | 0x02 | 0x03 | 0x81 | 0x82 | 0x83 =>
+ assert!(!matches!(ty, EcdsaSighashType::NonStandard(_))),
+ _ => assert_eq!(ty, EcdsaSighashType::NonStandard(n)),
+ }
+ }
+
+ // On replay-protected forks bits above the lowest byte are sometimes set.
+ let ty = EcdsaSighashType::from_consensus(0x0100_0041);
+ assert_eq!(ty, EcdsaSighashType::NonStandard(0x0100_0041));
+ assert_eq!(ty.to_u32(), 0x0100_0041);
+
+ assert_eq!(ty.to_consensus_u8(), 0x41);
+ }
+
+ #[test]
+ fn ecdsa_sighash_type_non_standard_is_single_uses_mask() {
+ use super::EcdsaSighashType;
+
+ assert!(EcdsaSighashType::NonStandard(0x63).is_single());
+ assert!(EcdsaSighashType::NonStandard(0xe3).is_single());
+ assert!(!EcdsaSighashType::NonStandard(0x65).is_single());
+ assert!(!EcdsaSighashType::NonStandard(0x62).is_single());
+ }
+
#[test]
#[cfg(feature = "alloc")]
fn ecdsasighashtype_fromstr_display() {Why this scored 47/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.