Skip to content

offers: BLIP 42 contacts with BIP 353 receive side and cross-compat vectors#89

Open
vincenzopalazzo wants to merge 10 commits into
blip42-basefrom
blip42-feature-complete
Open

offers: BLIP 42 contacts with BIP 353 receive side and cross-compat vectors#89
vincenzopalazzo wants to merge 10 commits into
blip42-basefrom
blip42-feature-complete

Conversation

@vincenzopalazzo

Copy link
Copy Markdown
Owner

This PR makes the BLIP 42 contacts implementation feature complete on the receive side, on top of the rebased work from the upstream PR lightningdevkit#4210.

What's new on top of the previous branch:

  • compute_contact_secret now re-derives the offer signing keys from the offer Nonce, which create_compact_offer_builder returns alongside the builder. The previous metadata-based derivation could never work for flow-built offers, the nonce only lives in the blinded path contexts. The new end-to-end test caught this!
  • Receive side for BIP 353 payer names (TLVs 2000001733/1735): we verify the BIP 340 signature over the invoice request merkle root (tag lightning || invoice_request || invreq_payer_bip_353_signature) and reject the invoice request when the name comes without a valid signature. Sending names is left for a follow-up.
  • InvoiceRequestFields::payer_contact applies the spec rule for known contacts: when the received secret matches an existing contact the payer offer and BIP 353 name are withheld by construction, so a leaked secret cannot redirect future payments to an impersonator offer.
  • Cross-compat vectors from lightning-kmp, the reference implementation (Add support for Bolt 12 contacts ACINQ/lightning-kmp#719): LDK parses their encoded invoice requests, verifies the payer name signature and re-serializes byte-for-byte. The official derivation vectors from the bLIP are covered too.
  • End-to-end functional test paying in both directions with contact fields, including the contact secret reconciliation between the two nodes.

Still out of scope, maybe in another PR: the sender side for BIP 353 names, and reusing the async-receive static offer as long-lived payer offer.

Spec: https://github.com/lightning/blips/blob/master/blip-0042.md

Note: the base branch blip42-base points to the upstream main commit this work is rebased on, so the diff shows only the 10 contacts commits.

This work was done with AI assistance, disclosed also in the commit messages via Co-Authored-By.

Signed-off-by: Vincenzo Palazzo vincenzopalazzodev@gmail.com

🤖 Generated with Claude Code

vincenzopalazzo and others added 10 commits June 9, 2026 23:49
Implements bLIP 42 contact secret derivation for mutual authentication
in Lightning Network payments.

- Add ContactSecret struct for TLV serialization with Readable/Writeable
- Add ContactSecrets for managing primary and additional remote secrets
- Add compute_contact_secret() for deterministic secret derivation
- Support offers with issuer_signing_pubkey and blinded paths

Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
…et, invreq_payer_offer

Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
Implements BLIP-42 contact management for the sender side:

- Add contact_secret and payer_offer fields to InvoiceRequestContents
- Add builder methods: contact_secrets(), payer_offer()
- Add accessor methods: contact_secret(), payer_offer()
- Add OptionalOfferPaymentParams fields for contact_secrects and payer_offer
- Update ChannelManager::pay_for_offer to pass contact information
- Add create_compact_offer_builder to OffersMessageFlow for small payer offers
- Update tests to include new InvoiceRequestFields

Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
Replace `Option<[u8; 32]>` with `Option<ContactSecret>` across the
invoice_request internal storage, the `InvoiceRequest::contact_secret()`
accessor, and the `InvoiceRequestFields` round-trip type. The wire
format is unchanged (`ContactSecret` writes/reads the same 32 raw bytes
via its existing `Writeable`/`Readable` impls), so this is a pure
type-system refactor.

Add `INVREQ_CONTACT_SECRET_TYPE` and `INVREQ_PAYER_OFFER_TYPE`
`pub(super)` constants in `offers::contacts`, mirroring the pattern of
`INVOICE_REQUEST_PAYER_ID_TYPE` and the other TLV-type constants in
`offers/`. Replace the numeric literals in
`ExperimentalInvoiceRequestTlvStream` with these constants so the BLIP
42 TLV numbers are defined in exactly one place.

Also document BLIP 42's anti-redirection rule on the public
`InvoiceRequestFields::contact_secret` field, since enforcement is the
application's responsibility — LDK does not own a contacts store.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`InvoiceRequestFields` is round-tripped through the blinded payment
path's `path_id` (via `Bolt12OfferContext`), so older LDK nodes can
end up parsing a serialization produced by a newer LDK. Per BOLT 1
"odd, it's OK; even, you die", optional/forward-compatible fields must
use odd TLV types so unknown-to-an-older-reader fields are skipped
rather than rejected with `DecodeError::UnknownRequiredField`.

The original PoC used even types 6 and 8 for `contact_secret` and
`payer_offer`, which would have broken forward compat between LDK
versions once shipped. Move them to odd 7 and 9. Reserve 11 and 13 for
`invreq_payer_bip_353_name` and `invreq_payer_bip_353_signature` in a
follow-up commit.

The wire format change is safe in-flight: PR lightningdevkit#4210 has not been
released, so no deployed consumer is reading types 6 or 8 today.

Flagged by `ldk-claude-review-bot` on the previous push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per bLIP 42, the contact secret is computed via ECDH between the
private key behind our offer's offer_node_id and the counterparty
offer's offer_node_id. The previous API took "our node identity" key,
which never matches the derived signing pubkey LDK puts in its offers,
so the counterparty could not reproduce the secret.

Re-derive the offer signing keys from the offer metadata instead and
expose the derivation as OffersMessageFlow::compute_contact_secret and
ChannelManager::compute_contact_secret. The freestanding helper now
takes a caller-provided secp context, propagates ECDH errors instead
of panicking, and documents which key it expects. Also fold
from_remote_secret into ContactSecrets, make secret matching
constant-time, document the contact lifecycle end-to-end, and
deduplicate the test vectors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Store the payer offer as a parsed Offer rather than raw bytes so that
malformed offers are rejected when the invoice request is parsed
instead of being silently dropped by accessors, and so accessors no
longer re-parse and clone on every call. Reading persisted
InvoiceRequestFields now propagates a decode error for corrupted
payer offer bytes for the same reason.

Also enforce the bLIP 42 size recommendation by failing to build an
invoice request whose payer offer exceeds PAYER_OFFER_MAX_BYTES,
right-size the experimental TLV allocation, and cover the new
behavior with builder, parsing, and round-trip tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flow-built offers with blinded paths don't carry derivable metadata: the
keys behind their signing pubkey are derived from a nonce that only
lives in the paths' OffersContext, so deriving contact secrets via
Offer::verify_using_metadata could never succeed. Pathless offers fare
no better as they advertise the node id, whose key isn't available for
the raw-point ECDH BLIP 42 requires.

Instead, require a blinded path in compact contact offers and hand the
caller the nonce used to build them, mirroring how the async receive
offer cache persists offers. compute_contact_secret then re-derives the
signing keys from that nonce via Offer::verify_using_recipient_data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Parse the invreq_payer_bip_353_name (2000001733) and
invreq_payer_bip_353_signature (2000001735) records, rejecting invoice
requests whose name comes without a signature or whose BIP 340
signature doesn't cover the invoice request's merkle root (sans the
top-level signature and the record itself) under the
lightning/invoice_request/invreq_payer_bip_353_signature tag. Sending
names is left for follow-up work. The verified name and committed offer
signing key are surfaced through InvoiceRequestFields so recipients can
later check the resolved offer via PayerBip353Name::matches_offer.

Also add InvoiceRequestFields::payer_contact, which applies the spec's
receive rules against the caller's contacts: when the received secret
matches a known contact the payer's offer and BIP 353 name are withheld
by construction, preventing a leaked secret from redirecting future
payments to an impersonator's offer.

Wire-format coverage comes from lightning-kmp (the BLIP 42 reference
implementation): LDK parses its encoded invoice requests, verifies the
payer name signature, and re-serializes byte-for-byte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pay an offer with contact fields attached and assert they survive the
trip through the invoice request, the invoice's blinded path data, and
the recipient's PaymentClaimable context. The reverse payment reuses
the same deterministically derived secret, letting both sides attribute
each other's payments to the same contact pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

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.

1 participant