Reject unreadable nSequence and fallback locktime instead of defaulting
What changed, and why it matters
This commit fixes a bug in Ledger's Bitcoin app where malformed PSBT data could silently be treated as if it were missing. Specifically, if the 'nSequence' or 'fallback locktime' fields were present but unreadable (wrong length or bad proof), the app previously substituted default values and continued signing. Now it correctly rejects such malformed inputs. This prevents a malicious or buggy wallet client from getting the device to sign a transaction using sequence or locktime values the client never actually committed to.
Review and merge; ensure the new error paths are exercised in device tests and that no other PSBT field accessors still silently default on ERROR.
Security signals we found
Silent defaulting on malformed PSBT fields could cause signing over uncommitted nSequence/locktime values
Fix distinguishes PSBT_FIELD_ABSENT from PSBT_FIELD_ERROR for critical consensus fields
BIP-0370 default now applied only when field is genuinely absent
Regression test added for over-long fallback locktime
Change affects all transaction hash/sighash computation paths
Evidence from the diff
The patch hardens PSBT field accessors and their callers. psbt_get_global_fallback_locktime now delegates to read_u32_le_field, which returns PSBT_FIELD_ERROR for wrong-length or proof failures instead of mapping all non-present statuses to ABSENT. Callers in txhashes.c (compute_tx_hashes, compute_sighash_legacy, compute_sighash_segwitv0, compute_sighash_segwitv1) now use a new get_nsequence_or_default helper that applies the BIP-0370 0xFFFFFFFF default only on PSBT_FIELD_ABSENT and aborts on PSBT_FIELD_ERROR. A unit test confirms an over-long fallback locktime is now an error rather than silently treated as absent.
Changed components
src/handler/sign_psbt/psbt_fields.csrc/handler/sign_psbt/psbt_fields.hsrc/handler/sign_psbt/txhashes.cunit-tests/test_psbt_fields.cInspect captured patch +62 / −33
### src/handler/sign_psbt/psbt_fields.c
@@ -116,20 +116,7 @@ psbt_field_status_t psbt_get_global_tx_version(dispatcher_context_t *dc,
psbt_field_status_t psbt_get_global_fallback_locktime(dispatcher_context_t *dc,
const merkleized_map_commitment_t *global_map,
uint32_t *out) {
- // Sized as a varint (9 bytes) so that a present-but-wrong-length value is classified as ERROR
- // rather than being rejected as "too long" (which would be indistinguishable from ABSENT).
- uint8_t raw[9];
- size_t len;
- psbt_field_status_t status =
- read_var(dc, global_map, PSBT_GLOBAL_FALLBACK_LOCKTIME, raw, sizeof(raw), &len);
- if (status != PSBT_FIELD_PRESENT) {
- return PSBT_FIELD_ABSENT;
- }
- if (len != 4) {
- return PSBT_FIELD_ERROR;
- }
- *out = read_u32_le(raw, 0);
- return PSBT_FIELD_PRESENT;
+ return read_u32_le_field(dc, global_map, PSBT_GLOBAL_FALLBACK_LOCKTIME, out);
}
/* -------------------------------------------------------------------------- */
### src/handler/sign_psbt/psbt_fields.h
@@ -65,9 +65,6 @@ psbt_field_status_t psbt_get_global_tx_version(dispatcher_context_t *dc,
/**
* PSBT_GLOBAL_FALLBACK_LOCKTIME: optional 4-byte little-endian locktime.
* On ABSENT the caller must use locktime 0 (BIP-0370); ERROR must abort.
- *
- * NOTE: ABSENT currently also absorbs read failures (a value too long for the buffer, or a failed
- * proof), which preserves the behaviour that predates this refactor. Tightened in a later commit.
*/
psbt_field_status_t psbt_get_global_fallback_locktime(dispatcher_context_t *dc,
const merkleized_map_commitment_t *global_map,
@@ -89,10 +86,9 @@ psbt_field_status_t psbt_get_input_prevout_index(dispatcher_context_t *dc,
/**
* PSBT_IN_SEQUENCE: optional 4-byte little-endian nSequence.
- * On ABSENT the caller must use the 0xFFFFFFFF default (BIP-0370).
- *
- * NOTE: the callers in txhashes.c currently also fall back to that default on ERROR, which
- * preserves the behaviour that predates this refactor. Tightened in a later commit.
+ * On ABSENT the caller must use the 0xFFFFFFFF default (BIP-0370); ERROR must abort. Note this is
+ * deliberately not folded into the accessor: a default returned from an error path would be signed
+ * over without the client ever having committed to it.
*/
psbt_field_status_t psbt_get_input_sequence(dispatcher_context_t *dc,
const merkleized_map_commitment_t *input_map,
### src/handler/sign_psbt/txhashes.c
@@ -78,6 +78,32 @@ static int update_hashes_with_map_value(dispatcher_context_t *dispatcher_context
&cb_state);
}
+/**
+ * Reads an input's nSequence, substituting the BIP-0370 default of 0xFFFFFFFF when the field is
+ * genuinely absent from the map.
+ *
+ * The default is applied only on PSBT_FIELD_ABSENT: a field that is present but unreadable (wrong
+ * length, or a failed Merkle proof) is an error, because silently defaulting there would hash an
+ * nSequence the client never committed to.
+ *
+ * Returns false after sending an error status word.
+ */
+static bool get_nsequence_or_default(dispatcher_context_t *dc,
+ const merkleized_map_commitment_t *map,
+ uint32_t *out) {
+ switch (psbt_get_input_sequence(dc, map, out)) {
+ case PSBT_FIELD_PRESENT:
+ return true;
+ case PSBT_FIELD_ABSENT:
+ *out = 0xFFFFFFFF;
+ return true;
+ default:
+ PRINTF("Malformed PSBT_IN_SEQUENCE\n");
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+}
+
// Updates the hash_context with the output of given index
// returns -1 on error. 0 on success.
static int hash_output_n(dispatcher_context_t *dc,
@@ -174,9 +200,8 @@ bool __attribute__((noinline)) compute_tx_hashes(dispatcher_context_t *dc,
crypto_hash_update(&sha_prevouts_context.header, ith_prevout_n_raw, 4);
uint32_t ith_nSequence;
- if (PSBT_FIELD_PRESENT != psbt_get_input_sequence(dc, &ith_map, &ith_nSequence)) {
- // if no PSBT_IN_SEQUENCE is present, we must assume nSequence 0xFFFFFFFF
- ith_nSequence = 0xFFFFFFFF;
+ if (!get_nsequence_or_default(dc, &ith_map, &ith_nSequence)) {
+ return false;
}
uint8_t ith_nSequence_raw[4];
@@ -346,9 +371,8 @@ bool __attribute__((noinline)) compute_sighash_legacy(dispatcher_context_t *dc,
}
uint32_t ith_nSequence;
- if (PSBT_FIELD_PRESENT != psbt_get_input_sequence(dc, &ith_map, &ith_nSequence)) {
- // if no PSBT_IN_SEQUENCE is present, we must assume nSequence 0xFFFFFFFF
- ith_nSequence = 0xFFFFFFFF;
+ if (!get_nsequence_or_default(dc, &ith_map, &ith_nSequence)) {
+ return false;
}
uint8_t ith_nSequence_raw[4];
@@ -502,9 +526,8 @@ bool __attribute__((noinline)) compute_sighash_segwitv0(
// nSequence
{
uint32_t nSequence;
- if (PSBT_FIELD_PRESENT != psbt_get_input_sequence(dc, input_map, &nSequence)) {
- // if no PSBT_IN_SEQUENCE is present, we must assume nSequence 0xFFFFFFFF
- nSequence = 0xFFFFFFFF;
+ if (!get_nsequence_or_default(dc, input_map, &nSequence)) {
+ return false;
}
uint8_t nSequence_raw[4];
@@ -631,9 +654,8 @@ bool __attribute__((noinline)) compute_sighash_segwitv1(
// nSequence
uint32_t nSequence;
- if (PSBT_FIELD_PRESENT != psbt_get_input_sequence(dc, input_map, &nSequence)) {
- // if no PSBT_IN_SEQUENCE is present, we must assume nSequence 0xFFFFFFFF
- nSequence = 0xFFFFFFFF;
+ if (!get_nsequence_or_default(dc, input_map, &nSequence)) {
+ return false;
}
write_u32_le(tmp, 0, nSequence);
crypto_hash_update(&sighash_context.header, tmp, 4);
### unit-tests/test_psbt_fields.c
@@ -167,6 +167,29 @@ static void test_fallback_locktime_wrong_length_is_error(void **state) {
assert_int_equal(got, 0xCAFEBABEu);
}
+/**
+ * Regression: the accessor used to read into a 9-byte buffer and map every negative result to
+ * PSBT_FIELD_ABSENT. A value longer than that buffer therefore came back as "absent", and the
+ * caller substituted locktime 0 for a field the client had actually committed to. It must be an
+ * error.
+ */
+static void test_fallback_locktime_over_buffer_is_error_not_absent(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ uint8_t very_long[12];
+ memset(very_long, 0x77, sizeof(very_long));
+
+ merkleized_map_commitment_t map;
+ map_with_one_field(mock, PSBT_GLOBAL_FALLBACK_LOCKTIME, very_long, sizeof(very_long), &map);
+
+ uint32_t got = 0xCAFEBABEu;
+ psbt_field_status_t status =
+ psbt_get_global_fallback_locktime(mock_dispatcher_get_dc(mock), &map, &got);
+
+ assert_int_equal(status, PSBT_FIELD_ERROR);
+ assert_int_equal(got, 0xCAFEBABEu);
+}
+
/* ---------- Mandatory fields ---------- */
static void test_prevout_txid_present(void **state) {
@@ -459,6 +482,7 @@ int main(void) {
T(test_fallback_locktime_present),
T(test_fallback_locktime_absent),
T(test_fallback_locktime_wrong_length_is_error),
+ T(test_fallback_locktime_over_buffer_is_error_not_absent),
T(test_prevout_txid_present),
T(test_prevout_txid_absent),
T(test_prevout_txid_short_is_error),Why this scored 62/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.