Skip to content

Encrypt payment_metadata when we build the payment secret#4628

Open
TheBlueMatt wants to merge 3 commits into
lightningdevkit:mainfrom
TheBlueMatt:2026-05-encrypt-metadata-internally
Open

Encrypt payment_metadata when we build the payment secret#4628
TheBlueMatt wants to merge 3 commits into
lightningdevkit:mainfrom
TheBlueMatt:2026-05-encrypt-metadata-internally

Conversation

@TheBlueMatt
Copy link
Copy Markdown
Collaborator

In 657ac8f we started committing to the payment_metadata in the payment_secret. We'd largely assumed that downstream code could simply encrypt the payment_metadata itself before passing it to lightning and decrypt before reading it from lightning. However, this presents a challenge - we'd very much love for that downstream code to avoid adding any extra bytes to its payment_metadata if at all possible, but it doesn't have a great way to get a decent IV without simply shoving it in the encrypted payment_metadata.

Instead, here, we encrypt and decrypt the payment_metadata internally in lightning. This allows us to reuse the IV that is used for lightning-generated payment_hashes as the IV for the encrypted payment_metadata as well. Sadly, we don't have any similar IV for user-provided payment_hashes. In that case, we simply accept the limitations and document that users must avoid encrypting multiple payment_metadatas for payments with the same payment_hash. This avoids padding the size of the payment_metadata and should generally not be a material concern - payment_hash reuse should generally not exist anyway, and if it does it should only be in cases where its "the same payment" being retried after failure, at which point payment_metadata should hopefully be the same.

In 657ac8f we started committing
to the `payment_metadata` in the `payment_secret`. We'd largely
assumed that downstream code could simply encrypt the
`payment_metadata` itself before passing it to `lightning` and
decrypt before reading it from `lightning`. However, this presents
a challenge - we'd very much love for that downstream code to avoid
adding any extra bytes to its `payment_metadata` if at all
possible, but it doesn't have a great way to get a decent IV
without simply shoving it in the encrypted `payment_metadata`.

Instead, here, we encrypt and decrypt the `payment_metadata`
internally in `lightning`. This allows us to reuse the IV that is
used for `lightning`-generated `payment_hash`es as the IV for the
encrypted `payment_metadata` as well. Sadly, we don't have any
similar IV for user-provided `payment_hash`es. In that case, we
simply accept the limitations and document that users must avoid
encrypting multiple `payment_metadata`s for payments with the same
`payment_hash`. This avoids padding the size of the
`payment_metadata` and should generally not be a material concern -
`payment_hash` reuse should generally not exist anyway, and if it
does it should only be in cases where its "the same payment" being
retried after failure, at which point `payment_metadata` should
hopefully be the same.
@TheBlueMatt TheBlueMatt requested a review from tnull May 20, 2026 20:50
@ldk-reviews-bot
Copy link
Copy Markdown

ldk-reviews-bot commented May 20, 2026

👋 Thanks for assigning @joostjager as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@TheBlueMatt TheBlueMatt requested a review from joostjager May 20, 2026 20:50
@TheBlueMatt TheBlueMatt added this to the 0.3 milestone May 20, 2026
Comment thread lightning/src/ln/channelmanager.rs Outdated
Comment thread lightning/src/ln/channelmanager.rs Outdated
@ldk-claude-review-bot
Copy link
Copy Markdown
Collaborator

ldk-claude-review-bot commented May 20, 2026

I've completed a thorough re-review of the entire diff. All issues I identified were already flagged in my prior review. No new issues found.

Review Summary

All previously identified issues remain applicable:

  1. lightning/src/ln/channelmanager.rs:15089 — Doc typo: payment_metadat should be payment_metadata, plus awkward sentence grammar.

  2. lightning/src/ln/max_payment_path_len_tests.rs:107-112 — Bug: create_inbound_payment_for_hash appends a 16-byte IV to encrypted metadata, but max_metadata_len was computed for plaintext size. The encrypted metadata overflows the 1300-byte onion limit. This affects the "max-sized metadata is sendable" test (line 112), the "too large" test (line 161), and the "two hop metadata" test (line 198).

  3. lightning/src/ln/inbound_payment.rs:248 — The 16-byte IV overhead for create_from_hash (UserPaymentHash path) is undocumented in the public API. Callers computing maximum metadata sizes for onion payloads would be surprised.

Verification of crypto correctness

  • Encrypt-then-MAC ordering is correct in both create and create_from_hash
  • HMAC inputs match between creation and verification for all three method types
  • ChaCha20 (key, nonce, counter) parameters match between encrypt and decrypt paths
  • Metadata length is committed in the HMAC, preventing length-manipulation
  • SpontaneousPayment correctly rejects metadata
  • get_payment_preimage correctly only handles LdkPaymentHash and errors for UserPaymentHash/SpontaneousPayment
  • No nonce reuse: LdkPaymentHash uses random IV from entropy source; UserPaymentHash uses a separate random IV appended to the ciphertext
  • In-place decryption via Option<&mut Vec<u8>> / Option<&mut [u8]> is correctly propagated through to PaymentClaimable events

Comment thread lightning/src/ln/channelmanager.rs Outdated
}

if let Some(metadata) = payment_metadata {
ChaCha20::new_from_block(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about following the PaymentMetadata / EncryptedPaymentMetadata state pattern we introduced in lightningdevkit/ldk-node#899?

We intentionally did that to improve readability and to use the type system to ensure we can't ever leak an unencrypted raw Vec<u8> into the metadata field.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I'm not seeing much opportunity to do this. lightning-invoice can't switch types as it has to handle counterparty data, so we have to make it a Vec<u8> again almost immediately. We could do it in PendingHTLCRouting::Receive/ReceiveKeysend but the structure in process_receive_htlcs is a bit annoying and I'm not entirely clear its worth it just for the inbound edge.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, okay, I think I would still prefer a bit more structured/typed approach but at the very least it would be good to make this some dedicated utility methods, if only to isolate all the unwraps in a single place rather than sprinkling them everywhere (and reviewers getting used to reading "unwrap").

iv_bytes.copy_from_slice(&rand_bytes[..IV_LEN]);

if let Some(metadata) = payment_metadata.as_mut() {
ChaCha20::new_from_block(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and the hash-based one are duplicated. Can it be extracted to helpers?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, we do it in a lot of places across the codebase, maybe its best if we do that separately.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and the hash-based one are duplicated. Can it be extracted to helpers?

Agree, essentially the same concern as above: #4628 (comment)

Comment thread lightning/src/ln/inbound_payment.rs Outdated
/// validation. Note that because we do not store an IV in the payment secret, and to avoid adding
/// any additional overhead in the data in the payment onion, the encrypted payment metadata *only
/// uses the payment hash as an IV*. Thus, you must never encrypt multiple `payment_metadata`s using
/// the same `payment_hash`!
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to check, is there really no way that a new invoice is generated with the same hash? Perhaps when the old one expires? Or in some uncommon swap protocol?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think its unlikely, but yea maybe its not worth saving the 16 bytes. I went ahead and added the IV.

Comment thread lightning/src/ln/inbound_payment.rs Outdated
metadata_iv_hmac.input(&info_bytes);
metadata_iv_hmac.input(&payment_hash.0);
metadata_iv_hmac.input(&(metadata.len() as u64).to_le_bytes());
let metadata_iv_hmac_bytes = Hmac::from_engine(metadata_iv_hmac).to_byte_array();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some explanation here why we create two IVs and how they differ would be helpful.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its just random now so probably doesn't need explanation.

Comment thread lightning/src/ln/channelmanager.rs Outdated
Comment on lines +107 to +112
let (payment_hash, payment_preimage, payment_secret, encrypted_metadata) =
get_payment_hash!(nodes[1], payment_metadata.clone());
let (mut route_0_1, ..) = get_route_and_payment_hash!(&nodes[0], &nodes[1], amt_msat);
let mut max_sized_onion = RecipientOnionFields {
payment_secret: Some(payment_secret),
payment_metadata: Some(payment_metadata.clone()),
payment_metadata: Some(encrypted_metadata),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: create_inbound_payment_for_hash (UserPaymentHash path) appends a 16-byte IV to the encrypted metadata (metadata.extend_from_slice(&iv_bytes) in inbound_payment.rs:247). So encrypted_metadata.len() == max_metadata_len + 16.

But max_metadata_len was computed (line 75) as the exact maximum number of metadata bytes that fit in a single-hop onion payload. Putting max_metadata_len + 16 encrypted bytes in payment_metadata should cause the onion packet to exceed the 1300-byte limit, making the send_payment at line 121 fail.

The fix: subtract IV_LEN (16) from max_metadata_len so that the encrypted output (plaintext + IV) fits exactly. The same issue propagates to two_hop_metadata (line 198) and to the "too large" test (line 161).

Suggested change
let (payment_hash, payment_preimage, payment_secret, encrypted_metadata) =
get_payment_hash!(nodes[1], payment_metadata.clone());
let (mut route_0_1, ..) = get_route_and_payment_hash!(&nodes[0], &nodes[1], amt_msat);
let mut max_sized_onion = RecipientOnionFields {
payment_secret: Some(payment_secret),
payment_metadata: Some(payment_metadata.clone()),
payment_metadata: Some(encrypted_metadata),
let (payment_hash, payment_preimage, payment_secret, encrypted_metadata) =
get_payment_hash!(nodes[1], payment_metadata.clone());
let (mut route_0_1, ..) = get_route_and_payment_hash!(&nodes[0], &nodes[1], amt_msat);
// Note: encrypted_metadata is max_metadata_len + IV_LEN (16) bytes for UserPaymentHash.
// max_metadata_len should account for this overhead.
let mut max_sized_onion = RecipientOnionFields {
payment_secret: Some(payment_secret),
payment_metadata: Some(encrypted_metadata),

Comment on lines +236 to +248
if let Some(metadata) = payment_metadata.as_mut() {
let mut iv_bytes = [0 as u8; IV_LEN];
let rand_bytes = entropy_source.get_secure_random_bytes();
iv_bytes.copy_from_slice(&rand_bytes[..IV_LEN]);

ChaCha20::new_from_block(
Key::new(keys.metadata_enc_key),
Nonce::new(iv_bytes[4..16].try_into().unwrap()),
u32::from_le_bytes(iv_bytes[..4].try_into().unwrap()),
)
.apply_keystream(metadata.as_mut_slice());
metadata.extend_from_slice(&iv_bytes);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 16-byte IV appended here makes create_from_hash return encrypted metadata that is 16 bytes longer than the plaintext input. This differs from create() (LdkPaymentHash path) where the encrypted metadata has the same length as the plaintext (because it reuses the payment-secret IV).

This size asymmetry isn't documented and can surprise callers who compute maximum metadata sizes for onion payloads. The docs for create_inbound_payment_for_hash (channelmanager.rs:15081) should mention the 16-byte overhead, e.g.:

Note: the returned encrypted metadata will be 16 bytes longer than the provided plaintext due to an appended encryption IV. Callers constructing invoices should account for this overhead when computing maximum metadata sizes for onion payloads.

Copy link
Copy Markdown
Contributor

@tnull tnull left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI is very failing right now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants