Skip to content

Commit 2d1a9a6

Browse files
Add contacts module for Lightning offer contact management
Implements BIP 353 human-readable contact addresses and bLIP 42 contact secret derivation for mutual authentication in Lightning Network payments. The implementation supports both offers with issuer_signing_pubkey and offers using blinded paths for privacy-preserving contact management. Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
1 parent 7f2d806 commit 2d1a9a6

File tree

2 files changed

+367
-0
lines changed

2 files changed

+367
-0
lines changed

lightning/src/offers/contacts.rs

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
// This file is Copyright its original authors, visible in version control
2+
// history.
3+
//
4+
// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
5+
// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
6+
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
7+
// You may not use this file except in accordance with one or both of these
8+
// licenses.
9+
10+
//! Data structures and utilities for managing Lightning Network contacts.
11+
//!
12+
//! Contacts are trusted people to which we may want to reveal our identity when paying them.
13+
//! We're also able to figure out when incoming payments have been made by one of our contacts.
14+
//! See [bLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details.
15+
16+
use crate::blinded_path::IntroductionNode;
17+
use crate::offers::offer::Offer;
18+
use bitcoin::hashes::{sha256, Hash, HashEngine};
19+
use bitcoin::secp256k1::Scalar;
20+
use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
21+
use core::fmt;
22+
23+
#[allow(unused_imports)]
24+
use crate::prelude::*;
25+
26+
/// BIP 353 human-readable address of a contact.
27+
///
28+
/// This represents an address in the form `name@domain` that can be used to identify
29+
/// a contact in a human-readable way.
30+
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
31+
pub struct ContactAddress {
32+
name: String,
33+
domain: String,
34+
}
35+
36+
impl ContactAddress {
37+
/// Creates a new [`ContactAddress`] with the given name and domain.
38+
///
39+
/// Returns `None` if either the name or domain exceeds 255 characters.
40+
pub fn new(name: String, domain: String) -> Result<Self, ()> {
41+
if name.len() >= 256 || domain.len() >= 256 {
42+
// TODO: return a better error string in here!
43+
return Err(());
44+
}
45+
Ok(Self { name, domain })
46+
}
47+
48+
/// Returns the name part of the contact address.
49+
pub fn name(&self) -> &str {
50+
&self.name
51+
}
52+
53+
/// Returns the domain part of the contact address.
54+
pub fn domain(&self) -> &str {
55+
&self.domain
56+
}
57+
58+
/// Parses a contact address from a string in the format `name@domain`.
59+
///
60+
/// The Bitcoin symbol (₿) is stripped if present.
61+
/// Returns `None` if the format is invalid or if name/domain exceed 255 characters.
62+
pub fn from_str(address: &str) -> Result<Self, ()> {
63+
let address = address.replace("₿", "");
64+
let parts: Vec<&str> = address.split('@').collect();
65+
66+
if parts.len() != 2 {
67+
return Err(());
68+
}
69+
70+
if parts[0].len() > 255 || parts[1].len() > 255 {
71+
return Err(());
72+
}
73+
74+
Self::new(parts[0].to_string(), parts[1].to_string())
75+
}
76+
}
77+
78+
impl fmt::Display for ContactAddress {
79+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
80+
write!(f, "{}@{}", self.name, self.domain)
81+
}
82+
}
83+
84+
/// When we receive an invoice_request containing a contact address, we don't immediately fetch
85+
/// the offer from the BIP 353 address, because this could otherwise be used as a DoS vector
86+
/// since we haven't received a payment yet.
87+
///
88+
/// After receiving the payment, we resolve the BIP 353 address to store the contact.
89+
/// In the invoice_request, they committed to the signing key used for their offer.
90+
/// We verify that the offer uses this signing key, otherwise the BIP 353 address most likely
91+
/// doesn't belong to them.
92+
#[derive(Clone, Debug, PartialEq, Eq)]
93+
pub struct UnverifiedContactAddress {
94+
address: ContactAddress,
95+
expected_offer_signing_key: PublicKey,
96+
}
97+
98+
// FIXME: this can be simply a function call?
99+
impl UnverifiedContactAddress {
100+
/// Creates a new [`UnverifiedContactAddress`].
101+
pub fn new(address: ContactAddress, expected_offer_signing_key: PublicKey) -> Self {
102+
Self { address, expected_offer_signing_key }
103+
}
104+
105+
/// Returns the contact address.
106+
pub fn address(&self) -> &ContactAddress {
107+
&self.address
108+
}
109+
110+
/// Returns the expected offer signing key.
111+
pub fn expected_offer_signing_key(&self) -> PublicKey {
112+
self.expected_offer_signing_key
113+
}
114+
115+
/// Verify that the offer obtained by resolving the BIP 353 address matches the
116+
/// invoice_request commitment.
117+
///
118+
/// If this returns false, it means that either:
119+
/// - the contact address doesn't belong to the node
120+
/// - or they changed the signing key of the offer associated with their BIP 353 address
121+
///
122+
/// Since the second case should be very infrequent, it's more likely that the remote node
123+
/// is malicious and we shouldn't store them in our contacts list.
124+
pub fn verify(&self, offer: &Offer) -> bool {
125+
// Check if the expected key matches the offer's issuer ID
126+
if let Some(issuer_id) = offer.issuer_signing_pubkey() {
127+
if issuer_id == self.expected_offer_signing_key {
128+
return true;
129+
}
130+
}
131+
132+
// Check if the expected key matches any of the blinded path node IDs
133+
for path in offer.paths() {
134+
if let IntroductionNode::NodeId(node_id) = path.introduction_node() {
135+
if *node_id == self.expected_offer_signing_key {
136+
return true;
137+
}
138+
}
139+
}
140+
141+
false
142+
}
143+
}
144+
145+
/// Contact secrets are used to mutually authenticate payments.
146+
///
147+
/// The first node to add the other to its contacts list will generate the `primary_secret` and
148+
/// send it when paying. If the second node adds the first node to its contacts list from the
149+
/// received payment, it will use the same `primary_secret` and both nodes are able to identify
150+
/// payments from each other.
151+
///
152+
/// But if the second node independently added the first node to its contacts list, it may have
153+
/// generated a different `primary_secret`. Each node has a different `primary_secret`, but they
154+
/// will store the other node's `primary_secret` in their `additional_remote_secrets`, which lets
155+
/// them correctly identify payments.
156+
///
157+
/// When sending a payment, we must always send the `primary_secret`.
158+
/// When receiving payments, we must check if the received contact_secret matches either the
159+
/// `primary_secret` or any of the `additional_remote_secrets`.
160+
#[derive(Clone, Debug, PartialEq, Eq)]
161+
pub struct ContactSecrets {
162+
primary_secret: [u8; 32],
163+
additional_remote_secrets: Vec<[u8; 32]>,
164+
}
165+
166+
impl ContactSecrets {
167+
/// Creates a new [`ContactSecrets`] with the given primary secret.
168+
pub fn new(primary_secret: [u8; 32]) -> Self {
169+
Self { primary_secret, additional_remote_secrets: Vec::new() }
170+
}
171+
172+
/// Creates a new [`ContactSecrets`] with the given primary secret and additional remote secrets.
173+
pub fn with_additional_secrets(
174+
primary_secret: [u8; 32], additional_remote_secrets: Vec<[u8; 32]>,
175+
) -> Self {
176+
Self { primary_secret, additional_remote_secrets }
177+
}
178+
179+
/// Returns the primary secret.
180+
pub fn primary_secret(&self) -> &[u8; 32] {
181+
&self.primary_secret
182+
}
183+
184+
/// Returns the additional remote secrets.
185+
pub fn additional_remote_secrets(&self) -> &[[u8; 32]] {
186+
&self.additional_remote_secrets
187+
}
188+
189+
/// This function should be used when we attribute an incoming payment to an existing contact.
190+
///
191+
/// This can be necessary when:
192+
/// - our contact added us without using the contact_secret we initially sent them
193+
/// - our contact is using a different wallet from the one(s) we have already stored
194+
pub fn add_remote_secret(&mut self, remote_secret: [u8; 32]) {
195+
if !self.additional_remote_secrets.contains(&remote_secret) {
196+
self.additional_remote_secrets.push(remote_secret);
197+
}
198+
}
199+
200+
/// Checks if the given secret matches either the primary secret or any additional remote secret.
201+
pub fn matches(&self, secret: &[u8; 32]) -> bool {
202+
&self.primary_secret == secret || self.additional_remote_secrets.contains(secret)
203+
}
204+
}
205+
206+
/// We derive our contact secret deterministically based on our offer and our contact's offer.
207+
///
208+
/// This provides a few interesting properties:
209+
/// - if we remove a contact and re-add it using the same offer, we will generate the same
210+
/// contact secret
211+
/// - if our contact is using the same deterministic algorithm with a single static offer, they
212+
/// will also generate the same contact secret
213+
///
214+
/// Note that this function must only be used when adding a contact that hasn't paid us before.
215+
/// If we're adding a contact that paid us before, we must use the contact_secret they sent us,
216+
/// which ensures that when we pay them, they'll be able to know it was coming from us (see
217+
/// [`from_remote_secret`]).
218+
///
219+
/// # Arguments
220+
/// * `our_private_key` - The private key associated with our node identity
221+
/// * `their_public_key` - The public key of the contact's node identity
222+
pub fn compute_contact_secret(our_private_key: &SecretKey, their_offer: &Offer) -> ContactSecrets {
223+
let offer_node_id = if let Some(issuer) = their_offer.issuer_signing_pubkey() {
224+
// If the offer has an issuer signing key, use it
225+
issuer
226+
} else {
227+
// Otherwise, use the last node in the first blinded path (if any)
228+
let node_ids = their_offer
229+
.paths()
230+
.iter()
231+
.filter_map(|path| path.blinded_hops().last())
232+
.map(|hop| hop.blinded_node_id)
233+
.collect::<Vec<_>>();
234+
if node_ids.is_empty() {
235+
// FIXME: do not panic but return a proper error!
236+
panic!("Offer must have either an issuer signing key or a blinded path");
237+
}
238+
node_ids[0]
239+
};
240+
// Compute ECDH shared secret (multiply their public key by our private key)
241+
let scalar: Scalar = our_private_key.clone().into();
242+
let secp = Secp256k1::new();
243+
let ecdh = offer_node_id.mul_tweak(&secp, &scalar).expect("Multiply");
244+
// Hash the shared secret with the bLIP 42 tag
245+
let mut engine = sha256::Hash::engine();
246+
engine.input(b"blip42_contact_secret");
247+
engine.input(&ecdh.serialize());
248+
let primary_secret = sha256::Hash::from_engine(engine).to_byte_array();
249+
250+
ContactSecrets::new(primary_secret)
251+
}
252+
253+
/// When adding a contact from which we've received a payment, we must use the contact_secret
254+
/// they sent us: this ensures that they'll be able to identify payments coming from us.
255+
pub fn from_remote_secret(remote_secret: [u8; 32]) -> ContactSecrets {
256+
ContactSecrets::new(remote_secret)
257+
}
258+
259+
#[cfg(test)]
260+
mod tests {
261+
use super::*;
262+
use bitcoin::{hex::DisplayHex, secp256k1::Secp256k1};
263+
use core::str::FromStr;
264+
265+
// FIXME: there is a better way to have test vectors? Loading them from
266+
// the json file for instance?
267+
268+
// derive deterministic contact_secret when both offers use blinded paths only
269+
#[test]
270+
fn test_compute_contact_secret_test_vector_blinded_paths() {
271+
let alice_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsrejlwh4vyz70s46r62vtakl4sxztqj6gxjged0wx0ly8qtrygufcsyq5agaes6v605af5rr9ydnj9srneudvrmc73n7evp72tzpqcnd28puqr8a3wmcff9wfjwgk32650vl747m2ev4zsjagzucntctlmcpc6vhmdnxlywneg5caqz0ansr45z2faxq7unegzsnyuduzys7kzyugpwcmhdqqj0h70zy92p75pseunclwsrwhaelvsqy9zsejcytxulndppmykcznn7y5h";
272+
let alice_priv_key =
273+
SecretKey::from_str("4ed1a01dae275f7b7ba503dbae23dddd774a8d5f64788ef7a768ed647dd0e1eb")
274+
.unwrap();
275+
let alice_offer = Offer::from_str(alice_offer_str).unwrap();
276+
277+
assert!(alice_offer.issuer_signing_pubkey().is_none());
278+
assert_eq!(alice_offer.paths().len(), 1);
279+
280+
let alice_offer_node_id = alice_offer
281+
.paths()
282+
.iter()
283+
.filter_map(|path| path.blinded_hops().last())
284+
.map(|hop| hop.blinded_node_id)
285+
.collect::<Vec<_>>();
286+
let alice_offer_node_id = alice_offer_node_id.first().unwrap();
287+
assert_eq!(
288+
alice_offer_node_id.to_string(),
289+
"0284c9c6f04487ac22710176377680127dfcf110aa0fa8186793c7dd01bafdcfd9"
290+
);
291+
292+
let bob_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsz4n88s74qhussxsu0vs3c4unck4yelk67zdc29ree3sztvjn7pc9qyqlcpj54jnj67aa9rd2n5dhjlxyfmv3vgqymrks2nf7gnf5u200mn5qrxfrxh9d0ug43j5egklhwgyrfv3n84gyjd2aajhwqxa0cc7zn37sncrwptz4uhlp523l83xpjx9dw72spzecrtex3ku3h3xpepeuend5rtmurekfmnqsq6kva9yr4k3dtplku9v6qqyxr5ep6lls3hvrqyt9y7htaz9qj";
293+
let bob_priv_key =
294+
SecretKey::from_str("12afb8248c7336e6aea5fe247bc4bac5dcabfb6017bd67b32c8195a6c56b8333")
295+
.unwrap();
296+
let bob_offer = Offer::from_str(bob_offer_str).unwrap();
297+
assert!(bob_offer.issuer_signing_pubkey().is_none());
298+
assert_eq!(bob_offer.paths().len(), 1);
299+
300+
let bob_offer_node_id = bob_offer
301+
.paths()
302+
.iter()
303+
.filter_map(|path| path.blinded_hops().last())
304+
.map(|hop| hop.blinded_node_id)
305+
.collect::<Vec<_>>();
306+
let bob_offer_node_id = bob_offer_node_id.first().unwrap();
307+
assert_eq!(
308+
bob_offer_node_id.to_string(),
309+
"035e4d1b7237898390e7999b6835ef83cd93b98200d599d29075b45ab0fedc2b34"
310+
);
311+
312+
let alice_computed = compute_contact_secret(&alice_priv_key, &bob_offer);
313+
let bob_computed = compute_contact_secret(&bob_priv_key, &alice_offer);
314+
315+
assert_eq!(
316+
alice_computed.primary_secret().to_hex_string(bitcoin::hex::Case::Lower),
317+
"810641fab614f8bc1441131dc50b132fd4d1e2ccd36f84b887bbab3a6d8cc3d8".to_owned()
318+
);
319+
assert_eq!(alice_computed, bob_computed);
320+
}
321+
322+
// derive deterministic contact_secret when one offer uses both blinded paths and issuer_id
323+
#[test]
324+
fn test_compute_contact_secret_test_vector_blinded_paths_and_issuer_id() {
325+
let alice_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsrejlwh4vyz70s46r62vtakl4sxztqj6gxjged0wx0ly8qtrygufcsyq5agaes6v605af5rr9ydnj9srneudvrmc73n7evp72tzpqcnd28puqr8a3wmcff9wfjwgk32650vl747m2ev4zsjagzucntctlmcpc6vhmdnxlywneg5caqz0ansr45z2faxq7unegzsnyuduzys7kzyugpwcmhdqqj0h70zy92p75pseunclwsrwhaelvsqy9zsejcytxulndppmykcznn7y5h";
326+
let alice_priv_key =
327+
SecretKey::from_str("4ed1a01dae275f7b7ba503dbae23dddd774a8d5f64788ef7a768ed647dd0e1eb")
328+
.unwrap();
329+
let alice_offer = Offer::from_str(alice_offer_str).unwrap();
330+
331+
assert!(alice_offer.issuer_signing_pubkey().is_none());
332+
assert_eq!(alice_offer.paths().len(), 1);
333+
334+
let alice_offer_node_id = alice_offer
335+
.paths()
336+
.iter()
337+
.filter_map(|path| path.blinded_hops().last())
338+
.map(|hop| hop.blinded_node_id)
339+
.collect::<Vec<_>>();
340+
let alice_offer_node_id = alice_offer_node_id.first().unwrap();
341+
assert_eq!(
342+
alice_offer_node_id.to_string(),
343+
"0284c9c6f04487ac22710176377680127dfcf110aa0fa8186793c7dd01bafdcfd9"
344+
);
345+
346+
let bob_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsz4n88s74qhussxsu0vs3c4unck4yelk67zdc29ree3sztvjn7pc9qyqlcpj54jnj67aa9rd2n5dhjlxyfmv3vgqymrks2nf7gnf5u200mn5qrxfrxh9d0ug43j5egklhwgyrfv3n84gyjd2aajhwqxa0cc7zn37sncrwptz4uhlp523l83xpjx9dw72spzecrtex3ku3h3xpepeuend5rtmurekfmnqsq6kva9yr4k3dtplku9v6qqyxr5ep6lls3hvrqyt9y7htaz9qjzcssy065ctv38c5h03lu0hlvq2t4p5fg6u668y6pmzcg64hmdm050jxx";
347+
let bob_priv_key =
348+
SecretKey::from_str("bcaafa8ed73da11437ce58c7b3458567a870168c0da325a40292fed126b97845")
349+
.unwrap();
350+
let bob_offer = Offer::from_str(bob_offer_str).unwrap();
351+
let bob_offer_node_id = bob_offer.issuer_signing_pubkey().unwrap();
352+
assert_eq!(
353+
bob_offer_node_id.to_string(),
354+
"023f54c2d913e2977c7fc7dfec029750d128d735a39341d8b08d56fb6edf47c8c6"
355+
);
356+
357+
let alice_computed = compute_contact_secret(&alice_priv_key, &bob_offer);
358+
let bob_computed = compute_contact_secret(&bob_priv_key, &alice_offer);
359+
360+
assert_eq!(
361+
alice_computed.primary_secret().to_hex_string(bitcoin::hex::Case::Lower),
362+
"4e0aa72cc42eae9f8dc7c6d2975bbe655683ada2e9abfdfe9f299d391ed9736c".to_owned()
363+
);
364+
assert_eq!(alice_computed, bob_computed);
365+
}
366+
}

lightning/src/offers/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub mod offer;
1717
pub mod flow;
1818

1919
pub mod async_receive_offer_cache;
20+
pub mod contacts;
2021
pub mod invoice;
2122
pub mod invoice_error;
2223
mod invoice_macros;

0 commit comments

Comments
 (0)