pset: examine outputs looking for unblinded liquid data
What changed, and why it matters
This commit changes how the Blockstream Jade hardware wallet examines transaction outputs before signing a Liquid (Elements-based) PSBT. It now inspects outputs for confidential/unblinded asset and value data, detects scriptless fee outputs, validates that fee outputs use the correct policy asset, and rejects blinded fee outputs. Previously, the code only tried to identify 'change' outputs on Bitcoin-like transactions. The change appears to be a security hardening/fix for Liquid transactions, but the commit message does not explicitly call it a security fix.
Treat as a security-relevant hardening commit for Liquid PSBT signing. Review whether prior firmware versions allowed blinded or wrong-asset fee outputs to be accepted, and assess whether a coordinated disclosure or advisory is warranted. Users signing Liquid transactions should upgrade to a firmware containing this commit.
Security signals we found
New validation rejects blinded scriptless outputs, which could previously be misinterpreted as fee outputs
New validation enforces policy asset for scriptless fee outputs on Liquid
New validation extracts and records unblinded asset/value data for Liquid outputs
Function now returns error rather than silently continuing when output validation fails
Call moved before amount sanity check, making output validation a prerequisite for signing
Evidence from the diff
The function validate_any_change_outputs() is renamed and restructured to validate_outputs(). It now returns a bool and accepts explicit_fee and errmsg out-parameters. For Liquid networks, it checks each output for asset/value commitments (confidential), blinding public keys, and explicit unblinded asset/value data. Scriptless outputs are treated as fees: they must be unblinded, must use the network policy asset, and their value is accumulated into explicit_fee. If these conditions fail, signing aborts with CBOR_RPC_BAD_PARAMETERS. The call is moved before the sanity-check on total output amounts and is now unconditional (no longer gated by signing_flags). This prevents a malicious or malformed Liquid PSBT from hiding fees in blinded scriptless outputs or using an unexpected asset for fees.
Changed components
main/process/sign_psbt.cLiquid/Elements PSBT signing flowOutput validation and fee detection logicInspect captured patch +71 / −10
diff --git a/main/process/sign_psbt.c b/main/process/sign_psbt.c
index 8d44753..0b97e26 100644
--- a/main/process/sign_psbt.c
+++ b/main/process/sign_psbt.c
@@ -452,23 +452,77 @@ static bool get_suitable_descriptor_record(const key_iter* iter, const uint32_t*
}
// Examine outputs for change we can automatically validate
-static void validate_any_change_outputs(const network_t network_id, struct wally_psbt* psbt,
- const uint8_t signing_flags, const char* wallet_name, const multisig_data_t* multisig_data,
- const descriptor_data_t* descriptor, output_info_t* output_info)
+static bool validate_outputs(const network_t network_id, struct wally_psbt* psbt, const uint8_t signing_flags,
+ const char* wallet_name, const multisig_data_t* multisig_data, const descriptor_data_t* descriptor,
+ output_info_t* output_info, uint64_t* explicit_fee, const char** errmsg)
{
JADE_ASSERT(network_id != NETWORK_NONE);
JADE_ASSERT(psbt);
- JADE_ASSERT(signing_flags);
// wallet_name, multisig_data and descriptor optional
JADE_ASSERT(output_info);
+ JADE_INIT_OUT_SIZE(explicit_fee);
+ JADE_INIT_OUT_PPTR(errmsg);
+ const bool is_liquid = network_is_liquid(network_id);
JADE_ASSERT(!multisig_data || !descriptor); // cannot have both
+ JADE_ASSERT(!is_liquid || !descriptor); // atm do not support liquid descriptors
+
+ uint8_t policy_asset[ASSET_TAG_LEN];
+ if (is_liquid) {
+ network_to_policy_asset(network_id, policy_asset, sizeof(policy_asset));
+ }
key_iter iter; // Holds any public key in use
// Check each output in turn
for (size_t index = 0; index < psbt->num_outputs; ++index) {
+ size_t written = 0;
output_info_t* const outinfo = output_info + index;
+
+ // If liquid, look for blinding data and explicit fees (scriptless outputs)
+ if (is_liquid) {
+ if ((wally_psbt_get_output_asset_commitment_len(psbt, index, &written) == WALLY_OK && written)
+ || (wally_psbt_get_output_value_commitment_len(psbt, index, &written) == WALLY_OK && written)) {
+ outinfo->flags |= OUTPUT_FLAG_CONFIDENTIAL;
+ }
+
+ if (wally_psbt_get_output_blinding_public_key(
+ psbt, index, outinfo->blinding_key, sizeof(outinfo->blinding_key), &written)
+ == WALLY_OK
+ && written) {
+ JADE_ASSERT(written == sizeof(outinfo->blinding_key));
+ outinfo->flags |= OUTPUT_FLAG_HAS_BLINDING_KEY;
+ }
+
+ if (wally_psbt_get_output_amount(psbt, index, &outinfo->value) == WALLY_OK
+ && wally_psbt_get_output_asset(psbt, index, outinfo->asset_id, sizeof(outinfo->asset_id), &written)
+ == WALLY_OK
+ && written) {
+ JADE_ASSERT(written == sizeof(outinfo->asset_id));
+ reverse_in_place(outinfo->asset_id, sizeof(outinfo->asset_id));
+ outinfo->flags |= OUTPUT_FLAG_HAS_UNBLINDED;
+ }
+
+ if (wally_psbt_get_output_script_len(psbt, index, &written) != WALLY_OK || !written) {
+ if (outinfo->flags & OUTPUT_FLAG_CONFIDENTIAL || !(outinfo->flags & OUTPUT_FLAG_HAS_UNBLINDED)) {
+ *errmsg = "Fee output (without script) cannot be blinded";
+ return false;
+ }
+
+ if (memcmp(outinfo->asset_id, policy_asset, sizeof(policy_asset))) {
+ *errmsg = "Unexpected fee output (without script) asset-id";
+ return false;
+ }
+
+ // Tally fees
+ *explicit_fee += outinfo->value;
+
+ // If is fee output, can't be change, so may as well skip now
+ JADE_ASSERT(!(outinfo->flags & (OUTPUT_FLAG_VALIDATED | OUTPUT_FLAG_CHANGE)));
+ continue;
+ }
+ }
+
JADE_LOGD("Considering output %u for change", index);
// By default, assume not a validated or change output, and so user must verify
@@ -613,6 +667,7 @@ static void validate_any_change_outputs(const network_t network_id, struct wally
"Ignoring multisig output %u as not signing only multisig inputs for a single registration", index);
}
}
+ return true;
}
// Sign a psbt - the passed wally psbt struct is updated with any signatures.
@@ -783,6 +838,18 @@ int sign_psbt(const network_t network_id, struct wally_psbt* psbt, const char**
} // is our key
} // iterate keys
+ // Examine outputs for liquid unblinded info and fees, and for change we can automatically validate
+ uint64_t explicit_fee = 0;
+ if (!validate_outputs(network_id, psbt, signing_flags, wallet_name, multisig_data, descriptor, output_info,
+ &explicit_fee, errmsg)) {
+ // errmsg will be populated
+ retval = CBOR_RPC_BAD_PARAMETERS;
+ goto cleanup;
+ }
+
+ // Explicit fee is only valid for Liquid
+ JADE_ASSERT(!explicit_fee || network_is_liquid(network_id));
+
// Sanity check amounts
uint64_t output_amount;
JADE_WALLY_VERIFY(wally_tx_get_total_output_satoshi(tx, &output_amount));
@@ -792,12 +859,6 @@ int sign_psbt(const network_t network_id, struct wally_psbt* psbt, const char**
goto cleanup;
}
- // Examine outputs for change we can automatically validate
- if (signing_flags) {
- validate_any_change_outputs(
- network_id, psbt, signing_flags, wallet_name, multisig_data, descriptor, output_info);
- }
-
// User to verify outputs and fee amount
if (!show_btc_transaction_outputs_activity(network_id, tx, output_info)) {
*errmsg = "User declined to sign psbt";
Why this scored 57/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.