What changed, and why it matters
This commit hardens the firmware's QR-code encoder used for the Unchained wallet export. It adds a safety flag so that if an invalid export request is rejected, the device won't accidentally reuse or leak a previously encoded QR message. It also fixes a fingerprint byte-order bug and switches from one export format to another for microSD exports. The changes are defensive hardening rather than a clear fix for an active exploit.
Treat as a defensive hardening patch. Review whether the `swab32` fingerprint change alters any existing on-chain or wallet-registration behavior, and confirm that microSD exports still produce the expected Unchained-compatible JSON. No urgent user action is indicated by the diff alone.
Security signals we found
State-lifetime hardening: stale encoder output no longer reachable after a failed `ur_encoder_start_raw` call
Safety documentation updated to place well-formed CBOR responsibility on the caller
Fingerprint byte-order change from hex-string conversion to `swab32`
Export-mode routing change for microSD from Unchained-specific JSON to generic multisig JSON
Unit test extended to verify empty output after a rejected raw encoder start
Evidence from the diff
The Rust UR encoder gains a started boolean. ur_encoder_start_raw now clears started before validation and sets it only on success; ur_encoder_next_part returns an empty string if the encoder was not started successfully. This prevents a failed raw-start from leaving stale encoder state accessible. The Python Unchained wallet code replaces xfp2str-based fingerprint encoding with swab32, corrects comments about required CBOR fields, and removes a BIP45-only filename pattern note while routing microSD exports through a generic multisig JSON helper instead of the previous Unchained-specific JSON.
Changed components
Foundation Passport firmware UR encoder (extmod/foundation-rust/src/ur/encoder.rs)Foundation Passport firmware UR encoder C header (extmod/foundation-rust/include/foundation.h)Unchained wallet export module (ports/stm32/boards/Passport/modules/wallets/unchained.py)Unchained unit tests (ports/stm32/boards/Passport/modules/tests/unit/unchained.py)Inspect captured patch +53 / −9
### extmod/foundation-rust/include/foundation.h
@@ -620,7 +620,8 @@ void ur_encoder_start(UR_Encoder *encoder,
* # Safety
*
* `ur_type` and `message` must be valid for reads of their respective
- * lengths for the duration of this call.
+ * lengths for the duration of this call. The caller is responsible for
+ * ensuring that `message` contains well-formed CBOR.
*/
bool ur_encoder_start_raw(UR_Encoder *encoder,
const uint8_t *ur_type,
@@ -634,8 +635,8 @@ bool ur_encoder_start_raw(UR_Encoder *encoder,
*
* # Safety
*
- * This function must not be called if `ur_encoder_start` was not called to
- * start the encoder. Or if the data used to start the encoder is freed.
+ * `ur` and `ur_len` must be valid for writes. If the encoder has not been
+ * started successfully, this function returns an empty string.
*
* # Return Value
*
### extmod/foundation-rust/src/ur/encoder.rs
@@ -55,6 +55,7 @@ pub const UR_ENCODER_MAX_MESSAGE_LEN: usize = UR_DECODER_MAX_MESSAGE_LEN;
#[cfg_attr(dtcm, link_section = ".dtcm")]
pub static mut UR_ENCODER: UR_Encoder = UR_Encoder {
inner: HeaplessEncoder::new(),
+ started: false,
};
/// cbindgen:ignore
@@ -83,6 +84,7 @@ pub struct UR_Encoder {
UR_ENCODER_MAX_FRAGMENT_LEN,
UR_ENCODER_MAX_SEQUENCE_COUNT,
>,
+ started: bool,
}
/// Start the encoder.
@@ -123,14 +125,16 @@ pub unsafe extern "C" fn ur_encoder_start(
message,
max_fragment_len(UR_MAX_TYPE, usize::MAX, max_chars),
);
+ encoder.started = true;
}
/// Start the encoder with an already CBOR-encoded Uniform Resource.
///
/// # Safety
///
/// `ur_type` and `message` must be valid for reads of their respective
-/// lengths for the duration of this call.
+/// lengths for the duration of this call. The caller is responsible for
+/// ensuring that `message` contains well-formed CBOR.
#[no_mangle]
pub unsafe extern "C" fn ur_encoder_start_raw(
encoder: &mut UR_Encoder,
@@ -140,6 +144,9 @@ pub unsafe extern "C" fn ur_encoder_start_raw(
message_len: usize,
max_chars: usize,
) -> bool {
+ // A rejected value must not leave the previously encoded UR available.
+ encoder.started = false;
+
let ur_type = unsafe { slice::from_raw_parts(ur_type, ur_type_len) };
let message = unsafe { slice::from_raw_parts(message, message_len) };
@@ -173,15 +180,16 @@ pub unsafe extern "C" fn ur_encoder_start_raw(
encoder_message,
max_fragment_len(UR_MAX_TYPE, usize::MAX, max_chars),
);
+ encoder.started = true;
true
}
/// Returns the UR corresponding to the next fountain encoded part.
///
/// # Safety
///
-/// This function must not be called if `ur_encoder_start` was not called to
-/// start the encoder. Or if the data used to start the encoder is freed.
+/// `ur` and `ur_len` must be valid for writes. If the encoder has not been
+/// started successfully, this function returns an empty string.
///
/// # Return Value
///
@@ -197,6 +205,13 @@ pub unsafe extern "C" fn ur_encoder_next_part(
ur: *mut *const c_char,
ur_len: *mut usize,
) {
+ if !encoder.started {
+ static EMPTY: &[u8] = b"\0";
+ *ur = EMPTY.as_ptr() as *const c_char;
+ *ur_len = 0;
+ return;
+ }
+
let part = encoder.inner.next_part();
let buf = unsafe { &mut *ptr::addr_of_mut!(UR_ENCODER_STRING) };
@@ -224,9 +239,11 @@ struct EndOfSlice;
#[cfg(test)]
mod tests {
use super::*;
+ use foundation_ur::{HeaplessDecoder, UR};
#[test]
fn raw_encoder_preserves_the_ur_type() {
+ // Encoder FFI storage is process-global, so keep its lifecycle in one test.
let ur_type = b"crypto-hdkey";
let message = b"\xa0";
@@ -247,6 +264,28 @@ mod tests {
let encoded =
slice::from_raw_parts(encoded as *const u8, encoded_len);
assert!(encoded.starts_with(b"ur:crypto-hdkey/"));
+
+ let encoded = str::from_utf8(encoded).unwrap();
+ let ur = UR::parse(encoded).unwrap();
+ let mut decoder: HeaplessDecoder<16, 2, 32, 8, 8, 16> =
+ HeaplessDecoder::new();
+ decoder.receive(ur).unwrap();
+ assert!(decoder.is_complete());
+ assert_eq!(decoder.message().unwrap().unwrap(), message);
+
+ assert!(!ur_encoder_start_raw(
+ encoder,
+ b"CRYPTO-HDKEY".as_ptr(),
+ b"CRYPTO-HDKEY".len(),
+ message.as_ptr(),
+ message.len(),
+ UR_ENCODER_MAX_STRING,
+ ));
+
+ let mut encoded = ptr::null();
+ let mut encoded_len = usize::MAX;
+ ur_encoder_next_part(encoder, &mut encoded, &mut encoded_len);
+ assert_eq!(encoded_len, 0);
}
}
}
### ports/stm32/boards/Passport/modules/tests/unit/unchained.py
@@ -29,6 +29,7 @@
'021af23f9fd2'
'0301'
'081af23f9fd2')
+# This pins the required field order and canonical CBOR encodings.
assert cbor == expected
testnet_cbor = create_unchained_hdkey_cbor(public_key, chain_code, 0, 0, True)
### ports/stm32/boards/Passport/modules/wallets/unchained.py
@@ -10,7 +10,7 @@
from data_codecs.qr_type import QRType
from foundation import ur
from public_constants import AF_P2SH
-from utils import xfp2str
+from utils import swab32
from .multisig_import import read_multisig_config_from_microsd, read_multisig_config_from_qr
from .multisig_json import create_multisig_json_wallet
@@ -43,6 +43,7 @@ def create_unchained_hdkey_cbor(public_key,
assert len(chain_code) == 32
result = bytearray()
+ # Keys 3, 4, 5, and 6 are required; key 8 is optional.
result.append(0xa4 + int(parent_fingerprint != 0))
result.extend(b'\x03\x58\x21')
result.extend(public_key)
@@ -54,6 +55,7 @@ def create_unchained_hdkey_cbor(public_key,
result.extend(b'\xa1\x02\x01' if is_testnet else b'\xa0')
result.extend(b'\x06\xd9\x01\x30') # crypto-keypath, tag 304
+ # Keys 1 and 3 are required; key 2 is optional.
result.append(0xa2 + int(source_fingerprint != 0))
result.extend(b'\x01\x82\x18\x2d\xf5') # m/45'
if source_fingerprint:
@@ -77,6 +79,8 @@ def create_unchained_export(sw_wallet=None,
qr_type=QRType.UR2):
assert multisig
+ # Unchained's QR registration is BIP45-only. The Sparrow-style microSD
+ # export includes BIP45 plus nested and native BIP48 keys.
if export_mode != 'qr':
return create_multisig_json_wallet(sw_wallet=sw_wallet,
addr_type=addr_type,
@@ -89,7 +93,7 @@ def create_unchained_export(sw_wallet=None,
chain = chains.current_chain()
with stash.SensitiveValues() as sv:
node = sv.derive_path("m/45'")
- source_fingerprint = int(xfp2str(settings.get('xfp')), 16)
+ source_fingerprint = swab32(settings.get('xfp', 0))
cbor = create_unchained_hdkey_cbor(node.public_key(),
node.chain_code(),
source_fingerprint,
@@ -111,7 +115,6 @@ def create_unchained_export(sw_wallet=None,
'export_modes': [
{'id': 'qr', 'label': 'QR Code', 'qr_type': QRType.UR2},
{'id': 'microsd', 'label': 'microSD',
- 'filename_pattern': '{xfp}-unchained.json',
'filename_pattern_multisig': '{xfp}-unchained-multisig.json'}
]
}Why this scored 42/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.