Skip to content

Instantly share code, notes, and snippets.

@vincenzopalazzo
Created May 1, 2026 18:39
Show Gist options
  • Select an option

  • Save vincenzopalazzo/b05b5c7fcb87af9282d073873158ca0f to your computer and use it in GitHub Desktop.

Select an option

Save vincenzopalazzo/b05b5c7fcb87af9282d073873158ca0f to your computer and use it in GitHub Desktop.

Yes — Option C is the right call, and your reasoning is exactly correct: the shared key K ships alongside the template offer in a single merchant→PoS handoff. No separate PoS keypair to register. From PoS's side, K is per-template state, not long-lived identity.

Concretely:

Setup (merchant → PoS, once per template):

K = random 32 bytes                       // generated by merchant
template_offer = OfferBuilder::deriving_signing_pubkey(...)
merchant ships {template_offer, K} → PoS  // application-layer channel; LDK doesn't care which

PoS makes a per-order modified offer:

order_info  = expected_amount_be || order_id || description
order_hash  = sha256(order_info)
mac         = HMAC-SHA256(K, order_hash)               // 32 bytes
plaintext   = order_hash || mac                        // 64 bytes
payment_token = ECIES_encrypt(offer_issuer_pubkey, plaintext)

Merchant verifies on verify_invoice_request:

plaintext = ECIES_decrypt(offer_issuer_privkey, payment_token)   // → 64 bytes (or fail)
order_hash, mac = plaintext[..32], plaintext[32..]
expected = HMAC-SHA256(K, order_hash)
if mac != expected: reject (forgery)                              // constant-time compare
// accept — order_hash is authenticated as originating from the legitimate PoS

Why this defeats the payer-forgery attack: an attacker who only knows the offer issuer pubkey can encrypt any 64 bytes to it, but they can't compute HMAC(K, order_hash) for ANY order_hash without K. Forgery requires K. Confidentiality (no order info leaks to the customer) comes from the encryption layer; authenticity comes from the MAC layer. Belt and suspenders.

Why this is better than B (PoS keypair): no long-lived PoS identity, no registration step. K is ephemeral per-template, generated on demand, shipped over the same channel that delivers the template itself. If a PoS terminal is replaced, the merchant just issues a new template + new K.

Differs from PR #86's symmetric approach how? PR #86 used ChaCha20Poly1305(K, plaintext) — pure symmetric AEAD. Anyone with K can both encrypt and decrypt. With Option C the customer has no key, so they can neither read nor forge; the merchant has the asymmetric privkey and the symmetric MAC key, so they can both decrypt and authenticate. Strictly stronger: a compromised K no longer leaks past order details to the attacker.


Rework checklist for PR 3 (after you confirm):

  1. Add types:
    • pub struct PaymentTokenKey([u8; 32]) — the per-template shared HMAC key. Constructors: new_random(&mut rng) and from_bytes([u8; 32]). Writeable/Readable so callers can persist it.
    • pub struct PaymentToken { order_hash: [u8; 32] } — the recovered order hash, after decryption + MAC verification.
  2. Add free functions:
    • pub fn encrypt_payment_token<T: Signing>(order_hash: [u8; 32], key: &PaymentTokenKey, merchant_pub: &PublicKey, secp_ctx: &Secp256k1<T>) -> Vec<u8> — ECDH(ephemeral, merchant_pub) → HKDF → ChaCha20Poly1305 of (order_hash || mac). Output: 33-byte ephemeral_pub || 64-byte ciphertext || 16-byte Poly1305 tag ≈ 113 bytes.
    • pub fn decrypt_payment_token<T: Verification>(token: &[u8], key: &PaymentTokenKey, merchant_keypair: &Keypair, secp_ctx: &Secp256k1<T>) -> Result<PaymentToken, PaymentTokenError> — performs decryption, then constant-time MAC compare; errors map to MalformedToken | DecryptionFailed | MacMismatch.
  3. Drop PaymentNotificationDigest from PR 3 — the user wanted to defer notification-signing decisions. We can revisit later; PR 3 becomes purely about the offer-side token.
  4. Consequence for PR 4: I'll need to remove the signature field from PaymentNotification for now (since it depended on PaymentNotificationDigest). The notification becomes (order_hash, payment_hash, amount_msat) — authenticity comes from the blinded notification path established by the merchant.

What to use under the hood for the AEAD? Workspace already pulls in ChaCha20Poly1305RFC (used in crypto::chacha20poly1305rfc). HKDF lives in crypto::utils. ECDH is bitcoin::secp256k1::ecdh. Zero new deps.

Confirm and I'll:

  1. Reset and rewrite the PR 3 commit ([upstream PR 3] offers: signed payment token primitives[upstream PR 3] offers: encrypted-and-MAC'd payment token primitives for BLIP-0056).
  2. Revise PR 4 commit to drop the signature field on PaymentNotification.
  3. Force-push the branch (it's a draft, so safe to rewrite history).
  4. Edit the PR body to replace the "signed-hash token" line with the corrected description.

Want me to proceed?

PR: vincenzopalazzo/rust-lightning#87

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