Skip to content

Commit 559a784

Browse files
authored
Merge pull request #3750 from TheBlueMatt/2025-04-utf8-split-panic
avoid panic when truncating payer_note in UTF-8 code point
2 parents 233aa39 + 5309176 commit 559a784

File tree

3 files changed

+121
-28
lines changed

3 files changed

+121
-28
lines changed

fuzz/src/invoice_request_deser.rs

+18-8
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,26 @@ fn build_response<T: secp256k1::Signing + secp256k1::Verification>(
8585
let expanded_key = ExpandedKey::new([42; 32]);
8686
let entropy_source = Randomness {};
8787
let nonce = Nonce::from_entropy_source(&entropy_source);
88+
89+
let invoice_request_fields =
90+
if let Ok(ver) = invoice_request.clone().verify_using_metadata(&expanded_key, secp_ctx) {
91+
// Previously we had a panic where we'd truncate the payer note possibly cutting a
92+
// Unicode character in two here, so try to fetch fields if we can validate.
93+
ver.fields()
94+
} else {
95+
InvoiceRequestFields {
96+
payer_signing_pubkey: invoice_request.payer_signing_pubkey(),
97+
quantity: invoice_request.quantity(),
98+
payer_note_truncated: invoice_request
99+
.payer_note()
100+
.map(|s| UntrustedString(s.to_string())),
101+
human_readable_name: None,
102+
}
103+
};
104+
88105
let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext {
89106
offer_id: OfferId([42; 32]),
90-
invoice_request: InvoiceRequestFields {
91-
payer_signing_pubkey: invoice_request.payer_signing_pubkey(),
92-
quantity: invoice_request.quantity(),
93-
payer_note_truncated: invoice_request
94-
.payer_note()
95-
.map(|s| UntrustedString(s.to_string())),
96-
human_readable_name: None,
97-
},
107+
invoice_request: invoice_request_fields,
98108
});
99109
let payee_tlvs = UnauthenticatedReceiveTlvs {
100110
payment_secret: PaymentSecret([42; 32]),

lightning/src/offers/invoice_request.rs

+75-7
Original file line numberDiff line numberDiff line change
@@ -989,7 +989,14 @@ impl VerifiedInvoiceRequest {
989989
InvoiceWithDerivedSigningPubkeyBuilder
990990
);
991991

992-
pub(crate) fn fields(&self) -> InvoiceRequestFields {
992+
/// Fetch the [`InvoiceRequestFields`] for this verified invoice.
993+
///
994+
/// These are fields which we expect to be useful when receiving a payment for this invoice
995+
/// request, and include the returned [`InvoiceRequestFields`] in the
996+
/// [`PaymentContext::Bolt12Offer`].
997+
///
998+
/// [`PaymentContext::Bolt12Offer`]: crate::blinded_path::payment::PaymentContext::Bolt12Offer
999+
pub fn fields(&self) -> InvoiceRequestFields {
9931000
let InvoiceRequestContents {
9941001
payer_signing_pubkey,
9951002
inner: InvoiceRequestContentsWithoutPayerSigningPubkey { quantity, payer_note, .. },
@@ -998,15 +1005,37 @@ impl VerifiedInvoiceRequest {
9981005
InvoiceRequestFields {
9991006
payer_signing_pubkey: *payer_signing_pubkey,
10001007
quantity: *quantity,
1001-
payer_note_truncated: payer_note.clone().map(|mut s| {
1002-
s.truncate(PAYER_NOTE_LIMIT);
1003-
UntrustedString(s)
1004-
}),
1008+
payer_note_truncated: payer_note
1009+
.clone()
1010+
// Truncate the payer note to `PAYER_NOTE_LIMIT` bytes, rounding
1011+
// down to the nearest valid UTF-8 code point boundary.
1012+
.map(|s| UntrustedString(string_truncate_safe(s, PAYER_NOTE_LIMIT))),
10051013
human_readable_name: self.offer_from_hrn().clone(),
10061014
}
10071015
}
10081016
}
10091017

1018+
/// `String::truncate(new_len)` panics if you split inside a UTF-8 code point,
1019+
/// which would leave the `String` containing invalid UTF-8. This function will
1020+
/// instead truncate the string to the next smaller code point boundary so the
1021+
/// truncated string always remains valid UTF-8.
1022+
///
1023+
/// This can still split a grapheme cluster, but that's probably fine.
1024+
/// We'd otherwise have to pull in the `unicode-segmentation` crate and its big
1025+
/// unicode tables to find the next smaller grapheme cluster boundary.
1026+
fn string_truncate_safe(mut s: String, new_len: usize) -> String {
1027+
// Finds the largest byte index `x` not exceeding byte index `index` where
1028+
// `s.is_char_boundary(x)` is true.
1029+
// TODO(phlip9): remove when `std::str::floor_char_boundary` stabilizes.
1030+
let truncated_len = if new_len >= s.len() {
1031+
s.len()
1032+
} else {
1033+
(0..=new_len).rev().find(|idx| s.is_char_boundary(*idx)).unwrap_or(0)
1034+
};
1035+
s.truncate(truncated_len);
1036+
s
1037+
}
1038+
10101039
impl InvoiceRequestContents {
10111040
pub(super) fn metadata(&self) -> &[u8] {
10121041
self.inner.metadata()
@@ -1382,8 +1411,13 @@ pub struct InvoiceRequestFields {
13821411
}
13831412

13841413
/// The maximum number of characters included in [`InvoiceRequestFields::payer_note_truncated`].
1414+
#[cfg(not(fuzzing))]
13851415
pub const PAYER_NOTE_LIMIT: usize = 512;
13861416

1417+
/// The maximum number of characters included in [`InvoiceRequestFields::payer_note_truncated`].
1418+
#[cfg(fuzzing)]
1419+
pub const PAYER_NOTE_LIMIT: usize = 8;
1420+
13871421
impl Writeable for InvoiceRequestFields {
13881422
fn write<W: Writer>(&self, writer: &mut W) -> Result<(), io::Error> {
13891423
write_tlv_fields!(writer, {
@@ -1426,6 +1460,7 @@ mod tests {
14261460
use crate::ln::inbound_payment::ExpandedKey;
14271461
use crate::ln::msgs::{DecodeError, MAX_VALUE_MSAT};
14281462
use crate::offers::invoice::{Bolt12Invoice, SIGNATURE_TAG as INVOICE_SIGNATURE_TAG};
1463+
use crate::offers::invoice_request::string_truncate_safe;
14291464
use crate::offers::merkle::{self, SignatureTlvStreamRef, TaggedHash, TlvStream};
14301465
use crate::offers::nonce::Nonce;
14311466
#[cfg(not(c_bindings))]
@@ -2947,14 +2982,20 @@ mod tests {
29472982
.unwrap();
29482983
assert_eq!(offer.issuer_signing_pubkey(), Some(node_id));
29492984

2985+
// UTF-8 payer note that we can't naively `.truncate(PAYER_NOTE_LIMIT)`
2986+
// because it would split a multi-byte UTF-8 code point.
2987+
let payer_note = "❤️".repeat(86);
2988+
assert_eq!(payer_note.len(), PAYER_NOTE_LIMIT + 4);
2989+
let expected_payer_note = "❤️".repeat(85);
2990+
29502991
let invoice_request = offer
29512992
.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id)
29522993
.unwrap()
29532994
.chain(Network::Testnet)
29542995
.unwrap()
29552996
.quantity(1)
29562997
.unwrap()
2957-
.payer_note("0".repeat(PAYER_NOTE_LIMIT * 2))
2998+
.payer_note(payer_note)
29582999
.build_and_sign()
29593000
.unwrap();
29603001
match invoice_request.verify_using_metadata(&expanded_key, &secp_ctx) {
@@ -2966,7 +3007,7 @@ mod tests {
29663007
InvoiceRequestFields {
29673008
payer_signing_pubkey: invoice_request.payer_signing_pubkey(),
29683009
quantity: Some(1),
2969-
payer_note_truncated: Some(UntrustedString("0".repeat(PAYER_NOTE_LIMIT))),
3010+
payer_note_truncated: Some(UntrustedString(expected_payer_note)),
29703011
human_readable_name: None,
29713012
}
29723013
);
@@ -2981,4 +3022,31 @@ mod tests {
29813022
Err(_) => panic!("unexpected error"),
29823023
}
29833024
}
3025+
3026+
#[test]
3027+
fn test_string_truncate_safe() {
3028+
// We'll correctly truncate to the nearest UTF-8 code point boundary:
3029+
// ❤ variation-selector
3030+
// e29da4 efb88f
3031+
let s = String::from("❤️");
3032+
assert_eq!(s.len(), 6);
3033+
assert_eq!(s, string_truncate_safe(s.clone(), 7));
3034+
assert_eq!(s, string_truncate_safe(s.clone(), 6));
3035+
assert_eq!("❤", string_truncate_safe(s.clone(), 5));
3036+
assert_eq!("❤", string_truncate_safe(s.clone(), 4));
3037+
assert_eq!("❤", string_truncate_safe(s.clone(), 3));
3038+
assert_eq!("", string_truncate_safe(s.clone(), 2));
3039+
assert_eq!("", string_truncate_safe(s.clone(), 1));
3040+
assert_eq!("", string_truncate_safe(s.clone(), 0));
3041+
3042+
// Every byte in an ASCII string is also a full UTF-8 code point.
3043+
let s = String::from("my ASCII string!");
3044+
for new_len in 0..(s.len() + 5) {
3045+
if new_len >= s.len() {
3046+
assert_eq!(s, string_truncate_safe(s.clone(), new_len));
3047+
} else {
3048+
assert_eq!(s[..new_len], string_truncate_safe(s.clone(), new_len));
3049+
}
3050+
}
3051+
}
29843052
}

lightning/src/offers/signer.rs

+28-13
Original file line numberDiff line numberDiff line change
@@ -393,36 +393,51 @@ fn verify_metadata<T: secp256k1::Signing>(
393393
secp_ctx,
394394
&SecretKey::from_slice(hmac.as_byte_array()).unwrap(),
395395
);
396-
if fixed_time_eq(&signing_pubkey.serialize(), &derived_keys.public_key().serialize()) {
396+
#[allow(unused_mut)]
397+
let mut ok = fixed_time_eq(&signing_pubkey.serialize(), &derived_keys.public_key().serialize());
398+
#[cfg(fuzzing)]
399+
if metadata[0] & 1 == 0 {
400+
ok = true;
401+
}
402+
if ok {
397403
Ok(Some(derived_keys))
398404
} else {
399405
Err(())
400406
}
401-
} else if metadata[Nonce::LENGTH..].len() == Sha256::LEN {
402-
if fixed_time_eq(&metadata[Nonce::LENGTH..], &hmac.to_byte_array()) {
407+
} else {
408+
#[allow(unused_mut)]
409+
let mut ok = metadata.len() == Nonce::LENGTH + Sha256::LEN
410+
&& fixed_time_eq(&metadata[Nonce::LENGTH..], &hmac.to_byte_array());
411+
#[cfg(fuzzing)]
412+
if metadata.is_empty() || metadata[0] & 1 == 0 {
413+
ok = true;
414+
}
415+
if ok {
403416
Ok(None)
404417
} else {
405418
Err(())
406419
}
407-
} else {
408-
Err(())
409420
}
410421
}
411422

412423
fn hmac_for_message<'a>(
413424
metadata: &[u8], expanded_key: &ExpandedKey, iv_bytes: &[u8; IV_LEN],
414425
tlv_stream: impl core::iter::Iterator<Item = TlvRecord<'a>>,
415426
) -> Result<HmacEngine<Sha256>, ()> {
416-
if metadata.len() < Nonce::LENGTH {
417-
return Err(());
418-
}
419-
420-
let nonce = match Nonce::try_from(&metadata[..Nonce::LENGTH]) {
421-
Ok(nonce) => nonce,
422-
Err(_) => return Err(()),
423-
};
424427
let mut hmac = expanded_key.hmac_for_offer();
425428
hmac.input(iv_bytes);
429+
430+
let nonce = if metadata.len() < Nonce::LENGTH {
431+
// In fuzzing its relatively challenging for the fuzzer to find cases where we have issues
432+
// in a BOLT 12 object but also have a right-sized nonce. So instead we allow any size
433+
// nonce.
434+
if !cfg!(fuzzing) {
435+
return Err(());
436+
}
437+
Nonce::try_from(&[42; Nonce::LENGTH][..]).unwrap()
438+
} else {
439+
Nonce::try_from(&metadata[..Nonce::LENGTH])?
440+
};
426441
hmac.input(&nonce.0);
427442

428443
for record in tlv_stream {

0 commit comments

Comments
 (0)