Change PushBytes::read_scriptint to i32 return type
What changed, and why it matters
This commit changes a Bitcoin script number-reading function so it returns a 32-bit integer instead of a 64-bit one, and adds a separate function for reading the larger 5-byte numbers used by the CHECKLOCKTIMEVERIFY opcode. The change makes the API more accurately reflect what the code actually allows, and reduces the risk that callers accidentally treat a 5-byte value as a normal script number. It appears to be a defensive correctness fix rather than a response to an active exploit.
Review downstream callers of read_scriptint and read_scriptint_non_minimal to ensure they handle the new i32 return type correctly. Confirm that read_cltv_scriptint is used wherever 5-byte CLTV values are parsed. Consider whether this change warrants a semver bump because it alters public return types.
Security signals we found
API type narrowing to match actual accepted input range
Separation of normal script integers from CLTV-sized integers
Reduction of caller confusion between 4-byte and 5-byte script-number semantics
No new unsafe code, panics guarded by documented invariants
Evidence from the diff
PushBytes::read_scriptint previously returned Result
Changed components
bitcoin/src/blockdata/script/push_bytes.rsbitcoin/src/blockdata/script/mod.rsbitcoin/src/blockdata/script/instruction.rsbitcoin/src/blockdata/script/tests.rsInspect captured patch +46 / −13
diff --git a/bitcoin/src/blockdata/script/instruction.rs b/bitcoin/src/blockdata/script/instruction.rs
index 3f9fa854..e7037591 100644
--- a/bitcoin/src/blockdata/script/instruction.rs
+++ b/bitcoin/src/blockdata/script/instruction.rs
@@ -48,7 +48,7 @@ impl Instruction<'_> {
}
}
Instruction::PushBytes(bytes) =>
- super::read_scriptint_non_minimal(bytes.as_bytes()).ok(),
+ super::read_scriptint_non_minimal(bytes.as_bytes()).ok().map(i64::from),
}
}
@@ -77,7 +77,7 @@ impl Instruction<'_> {
_ => None,
}
}
- Instruction::PushBytes(bytes) => bytes.read_scriptint().ok(),
+ Instruction::PushBytes(bytes) => bytes.read_cltv_scriptint().ok(),
}
}
}
diff --git a/bitcoin/src/blockdata/script/mod.rs b/bitcoin/src/blockdata/script/mod.rs
index 065e4324..d7c343e6 100644
--- a/bitcoin/src/blockdata/script/mod.rs
+++ b/bitcoin/src/blockdata/script/mod.rs
@@ -155,7 +155,7 @@ pub fn write_scriptint(out: &mut [u8; 8], n: i64) -> usize {
///
/// See [`push_bytes::PushBytes::read_scriptint`] for a description of some subtleties of
/// this function.
-pub fn read_scriptint_non_minimal(v: &[u8]) -> Result<i64, Error> {
+pub fn read_scriptint_non_minimal(v: &[u8]) -> Result<i32, Error> {
if v.is_empty() {
return Ok(0);
}
@@ -163,7 +163,8 @@ pub fn read_scriptint_non_minimal(v: &[u8]) -> Result<i64, Error> {
return Err(Error::NumericOverflow);
}
- Ok(scriptint_parse(v))
+ let ret = scriptint_parse(v);
+ Ok(i32::try_from(ret).expect("4 bytes or less fits in an i32"))
}
// Caller to guarantee that `v` is not empty.
diff --git a/bitcoin/src/blockdata/script/push_bytes.rs b/bitcoin/src/blockdata/script/push_bytes.rs
index 820e315d..427b310f 100644
--- a/bitcoin/src/blockdata/script/push_bytes.rs
+++ b/bitcoin/src/blockdata/script/push_bytes.rs
@@ -293,6 +293,23 @@ impl PushBytes {
/// Decodes an integer in script(minimal CScriptNum) format.
///
+ /// This code is based on the
+ /// [`CScriptNum` constructor in Bitcoin Core](https://github.com/bitcoin/bitcoin/blob/99a4ddf5ab1b3e514d08b90ad8565827fda7b63b/src/script/script.h#L245)
+ ///
+ /// # Errors
+ ///
+ /// * [`script::Error::NumericOverflow`] if result is not in range [-2^31 +1...2^31 -1].
+ /// * [`script::Error::NonMinimalPush`] if encoding is non-minimal.
+ pub fn read_scriptint(&self) -> Result<i32, script::Error> {
+ // Cast is safe, since the function already checks for byte length > 4
+ let ret = self.read_scriptint_internal(4)?;
+ Ok(i32::try_from(ret).expect("4 bytes or less fits in an i32"))
+ }
+
+ /// Decodes an integer in script(minimal CScriptNum) format.
+ ///
+ /// This is suitable to read input values for CHECKLOCKTIMEVERIFY instructions.
+ ///
/// Notice that this fails on overflow: the result is the same as in bitcoind, that only 4-byte
/// signed-magnitude values may be read as numbers. They can be added or subtracted (and a long
/// time ago, multiplied and divided), and this may result in numbers which can't be written out
@@ -300,16 +317,31 @@ impl PushBytes {
/// bit crazy and subtle, but it makes sense: you can load 32-bit numbers and do anything with
/// them, which back when mult/div was allowed, could result in up to a 64-bit number. We don't
/// want overflow since that's surprising --- and we don't want numbers that don't fit in 64
- /// bits (for efficiency on modern processors) so we simply say, anything in excess of 32 bits
- /// is no longer a number. This is basically a ranged type implementation.
+ /// bits (for efficiency on modern processors). This function will return any value up to 40
+ /// bits in length. This is basically a ranged type implementation.
+ ///
+ /// This code is based on the
+ /// [`CScriptNum` constructor in Bitcoin Core](https://github.com/bitcoin/bitcoin/blob/99a4ddf5ab1b3e514d08b90ad8565827fda7b63b/src/script/script.h#L245)
+ ///
+ /// # Errors
+ ///
+ /// * [`script::Error::NumericOverflow`] if result is not in range [-2^39 +1...2^39 -1].
+ /// * [`script::Error::NonMinimalPush`] if encoding is non-minimal.
+ pub fn read_cltv_scriptint(&self) -> Result<i64, script::Error> {
+ self.read_scriptint_internal(5)
+ }
+
+ /// The internal implementation for reading a script integer.
///
- /// This code is based on the `CScriptNum` constructor in Bitcoin Core (see `script.h`).
- pub fn read_scriptint(&self) -> Result<i64, script::Error> {
+ /// As with `read_cltv_scriptint`, this returns an i64, since that is the maximum size we might
+ /// need to return data. In practice, if the max_size parameter is 4 or less, this function
+ /// will always return a value that can fit into an i32, and can thus be safely cast.
+ fn read_scriptint_internal(&self, max_size: usize) -> Result<i64, script::Error> {
let last = match self.as_bytes().last() {
Some(last) => last,
None => return Ok(0),
};
- if self.len() > 4 {
+ if self.len() > max_size {
return Err(script::Error::NumericOverflow);
}
// Comment and code copied from Bitcoin Core:
diff --git a/bitcoin/src/blockdata/script/tests.rs b/bitcoin/src/blockdata/script/tests.rs
index aa4dbb15..66fce858 100644
--- a/bitcoin/src/blockdata/script/tests.rs
+++ b/bitcoin/src/blockdata/script/tests.rs
@@ -395,16 +395,16 @@ fn scriptint_round_trip() {
Ok(i),
PushBytes::read_scriptint(
<&PushBytes>::try_from(build_scriptint(i).as_slice()).unwrap()
- )
+ ).map(i64::from)
);
assert_eq!(
Ok(-i),
PushBytes::read_scriptint(
<&PushBytes>::try_from(build_scriptint(-i).as_slice()).unwrap()
- )
+ ).map(i64::from)
);
- assert_eq!(Ok(i), read_scriptint_non_minimal(&build_scriptint(i)));
- assert_eq!(Ok(-i), read_scriptint_non_minimal(&build_scriptint(-i)));
+ assert_eq!(Ok(i), read_scriptint_non_minimal(&build_scriptint(i)).map(i64::from));
+ assert_eq!(Ok(-i), read_scriptint_non_minimal(&build_scriptint(-i)).map(i64::from));
}
assert!(PushBytes::read_scriptint(
<&PushBytes>::try_from(build_scriptint(1 << 31).as_slice()).unwrap()
Why this scored 35/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.