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):
- Add types:
pub struct PaymentTokenKey([u8; 32])— the per-template shared HMAC key. Constructors:new_random(&mut rng)andfrom_bytes([u8; 32]).Writeable/Readableso callers can persist it.pub struct PaymentToken { order_hash: [u8; 32] }— the recovered order hash, after decryption + MAC verification.
- 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 toMalformedToken | DecryptionFailed | MacMismatch.
- Drop
PaymentNotificationDigestfrom PR 3 — the user wanted to defer notification-signing decisions. We can revisit later; PR 3 becomes purely about the offer-side token. - Consequence for PR 4: I'll need to remove the
signaturefield fromPaymentNotificationfor now (since it depended onPaymentNotificationDigest). 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:
- 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). - Revise PR 4 commit to drop the signature field on
PaymentNotification. - Force-push the branch (it's a draft, so safe to rewrite history).
- Edit the PR body to replace the "signed-hash token" line with the corrected description.
Want me to proceed?