Skip to content

Commit 66b2d80

Browse files
Add contacts module for Lightning offer contact management
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>
1 parent 62c5849 commit 66b2d80

File tree

2 files changed

+296
-0
lines changed

2 files changed

+296
-0
lines changed

lightning/src/offers/contacts.rs

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
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::io::{self, Read};
17+
use crate::ln::msgs::DecodeError;
18+
use crate::offers::offer::Offer;
19+
use crate::util::ser::{Readable, Writeable, Writer};
20+
use bitcoin::hashes::{sha256, Hash, HashEngine};
21+
use bitcoin::secp256k1::Scalar;
22+
use bitcoin::secp256k1::{Secp256k1, SecretKey};
23+
24+
#[allow(unused_imports)]
25+
use crate::prelude::*;
26+
27+
/// A contact secret used in experimental TLV fields for BLIP-42.
28+
///
29+
/// This is a 32-byte secret that can be included in invoice requests to establish
30+
/// contact relationships between Lightning nodes.
31+
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
32+
pub struct ContactSecret {
33+
contents: [u8; 32],
34+
}
35+
36+
impl ContactSecret {
37+
/// Creates a new [`ContactSecret`] from a 32-byte array.
38+
pub fn new(contents: [u8; 32]) -> Self {
39+
Self { contents }
40+
}
41+
42+
/// Returns the inner 32-byte array.
43+
pub fn as_bytes(&self) -> &[u8; 32] {
44+
&self.contents
45+
}
46+
}
47+
48+
impl From<[u8; 32]> for ContactSecret {
49+
fn from(contents: [u8; 32]) -> Self {
50+
Self { contents }
51+
}
52+
}
53+
54+
impl AsRef<[u8; 32]> for ContactSecret {
55+
fn as_ref(&self) -> &[u8; 32] {
56+
&self.contents
57+
}
58+
}
59+
60+
impl Readable for ContactSecret {
61+
fn read<R: Read>(r: &mut R) -> Result<Self, DecodeError> {
62+
let mut buf = [0u8; 32];
63+
r.read_exact(&mut buf)?;
64+
Ok(ContactSecret { contents: buf })
65+
}
66+
}
67+
68+
impl Writeable for ContactSecret {
69+
fn write<W: Writer>(&self, w: &mut W) -> Result<(), io::Error> {
70+
w.write_all(&self.contents)
71+
}
72+
}
73+
74+
/// Contact secrets are used to mutually authenticate payments.
75+
///
76+
/// The first node to add the other to its contacts list will generate the `primary_secret` and
77+
/// send it when paying. If the second node adds the first node to its contacts list from the
78+
/// received payment, it will use the same `primary_secret` and both nodes are able to identify
79+
/// payments from each other.
80+
///
81+
/// But if the second node independently added the first node to its contacts list, it may have
82+
/// generated a different `primary_secret`. Each node has a different `primary_secret`, but they
83+
/// will store the other node's `primary_secret` in their `additional_remote_secrets`, which lets
84+
/// them correctly identify payments.
85+
///
86+
/// When sending a payment, we must always send the `primary_secret`.
87+
/// When receiving payments, we must check if the received contact_secret matches either the
88+
/// `primary_secret` or any of the `additional_remote_secrets`.
89+
#[derive(Clone, Debug, PartialEq, Eq)]
90+
pub struct ContactSecrets {
91+
primary_secret: ContactSecret,
92+
additional_remote_secrets: Vec<ContactSecret>,
93+
}
94+
95+
impl ContactSecrets {
96+
/// Creates a new [`ContactSecrets`] with the given primary secret.
97+
pub fn new(primary_secret: ContactSecret) -> Self {
98+
Self { primary_secret, additional_remote_secrets: Vec::new() }
99+
}
100+
101+
/// Creates a new [`ContactSecrets`] with the given primary secret and additional remote secrets.
102+
pub fn with_additional_secrets(
103+
primary_secret: ContactSecret, additional_remote_secrets: Vec<ContactSecret>,
104+
) -> Self {
105+
Self { primary_secret, additional_remote_secrets }
106+
}
107+
108+
/// Returns the primary secret.
109+
pub fn primary_secret(&self) -> &ContactSecret {
110+
&self.primary_secret
111+
}
112+
113+
/// Returns the additional remote secrets.
114+
pub fn additional_remote_secrets(&self) -> &[ContactSecret] {
115+
&self.additional_remote_secrets
116+
}
117+
118+
/// This function should be used when we attribute an incoming payment to an existing contact.
119+
///
120+
/// This can be necessary when:
121+
/// - our contact added us without using the contact_secret we initially sent them
122+
/// - our contact is using a different wallet from the one(s) we have already stored
123+
pub fn add_remote_secret(&mut self, remote_secret: ContactSecret) {
124+
if !self.additional_remote_secrets.contains(&remote_secret) {
125+
self.additional_remote_secrets.push(remote_secret);
126+
}
127+
}
128+
129+
/// Checks if the given secret matches either the primary secret or any additional remote secret.
130+
pub fn matches(&self, secret: &ContactSecret) -> bool {
131+
&self.primary_secret == secret || self.additional_remote_secrets.contains(secret)
132+
}
133+
}
134+
135+
/// We derive our contact secret deterministically based on our offer and our contact's offer.
136+
///
137+
/// This provides a few interesting properties:
138+
/// - if we remove a contact and re-add it using the same offer, we will generate the same
139+
/// contact secret
140+
/// - if our contact is using the same deterministic algorithm with a single static offer, they
141+
/// will also generate the same contact secret
142+
///
143+
/// Note that this function must only be used when adding a contact that hasn't paid us before.
144+
/// If we're adding a contact that paid us before, we must use the contact_secret they sent us,
145+
/// which ensures that when we pay them, they'll be able to know it was coming from us (see
146+
/// [`from_remote_secret`]).
147+
///
148+
/// # Arguments
149+
/// * `our_private_key` - The private key associated with our node identity
150+
/// * `their_public_key` - The public key of the contact's node identity
151+
pub fn compute_contact_secret(our_private_key: &SecretKey, their_offer: &Offer) -> ContactSecrets {
152+
let offer_node_id = if let Some(issuer) = their_offer.issuer_signing_pubkey() {
153+
// If the offer has an issuer signing key, use it
154+
issuer
155+
} else {
156+
// Otherwise, use the last node in the first blinded path (if any)
157+
let node_ids = their_offer
158+
.paths()
159+
.iter()
160+
.filter_map(|path| path.blinded_hops().last())
161+
.map(|hop| hop.blinded_node_id)
162+
.collect::<Vec<_>>();
163+
if node_ids.is_empty() {
164+
// FIXME: do not panic but return a proper error!
165+
panic!("Offer must have either an issuer signing key or a blinded path");
166+
}
167+
node_ids[0]
168+
};
169+
// Compute ECDH shared secret (multiply their public key by our private key)
170+
let scalar: Scalar = our_private_key.clone().into();
171+
let secp = Secp256k1::new();
172+
let ecdh = offer_node_id.mul_tweak(&secp, &scalar).expect("Multiply");
173+
// Hash the shared secret with the bLIP 42 tag
174+
let mut engine = sha256::Hash::engine();
175+
engine.input(b"blip42_contact_secret");
176+
engine.input(&ecdh.serialize());
177+
let primary_secret = ContactSecret::new(sha256::Hash::from_engine(engine).to_byte_array());
178+
179+
ContactSecrets::new(primary_secret)
180+
}
181+
182+
/// When adding a contact from which we've received a payment, we must use the contact_secret
183+
/// they sent us: this ensures that they'll be able to identify payments coming from us.
184+
pub fn from_remote_secret(remote_secret: ContactSecret) -> ContactSecrets {
185+
ContactSecrets::new(remote_secret)
186+
}
187+
188+
#[cfg(test)]
189+
mod tests {
190+
use super::*;
191+
use bitcoin::{hex::DisplayHex, secp256k1::Secp256k1};
192+
use core::str::FromStr;
193+
194+
// FIXME: there is a better way to have test vectors? Loading them from
195+
// the json file for instance?
196+
197+
// derive deterministic contact_secret when both offers use blinded paths only
198+
#[test]
199+
fn test_compute_contact_secret_test_vector_blinded_paths() {
200+
let alice_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsrejlwh4vyz70s46r62vtakl4sxztqj6gxjged0wx0ly8qtrygufcsyq5agaes6v605af5rr9ydnj9srneudvrmc73n7evp72tzpqcnd28puqr8a3wmcff9wfjwgk32650vl747m2ev4zsjagzucntctlmcpc6vhmdnxlywneg5caqz0ansr45z2faxq7unegzsnyuduzys7kzyugpwcmhdqqj0h70zy92p75pseunclwsrwhaelvsqy9zsejcytxulndppmykcznn7y5h";
201+
let alice_priv_key =
202+
SecretKey::from_str("4ed1a01dae275f7b7ba503dbae23dddd774a8d5f64788ef7a768ed647dd0e1eb")
203+
.unwrap();
204+
let alice_offer = Offer::from_str(alice_offer_str).unwrap();
205+
206+
assert!(alice_offer.issuer_signing_pubkey().is_none());
207+
assert_eq!(alice_offer.paths().len(), 1);
208+
209+
let alice_offer_node_id = alice_offer
210+
.paths()
211+
.iter()
212+
.filter_map(|path| path.blinded_hops().last())
213+
.map(|hop| hop.blinded_node_id)
214+
.collect::<Vec<_>>();
215+
let alice_offer_node_id = alice_offer_node_id.first().unwrap();
216+
assert_eq!(
217+
alice_offer_node_id.to_string(),
218+
"0284c9c6f04487ac22710176377680127dfcf110aa0fa8186793c7dd01bafdcfd9"
219+
);
220+
221+
let bob_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsz4n88s74qhussxsu0vs3c4unck4yelk67zdc29ree3sztvjn7pc9qyqlcpj54jnj67aa9rd2n5dhjlxyfmv3vgqymrks2nf7gnf5u200mn5qrxfrxh9d0ug43j5egklhwgyrfv3n84gyjd2aajhwqxa0cc7zn37sncrwptz4uhlp523l83xpjx9dw72spzecrtex3ku3h3xpepeuend5rtmurekfmnqsq6kva9yr4k3dtplku9v6qqyxr5ep6lls3hvrqyt9y7htaz9qj";
222+
let bob_priv_key =
223+
SecretKey::from_str("12afb8248c7336e6aea5fe247bc4bac5dcabfb6017bd67b32c8195a6c56b8333")
224+
.unwrap();
225+
let bob_offer = Offer::from_str(bob_offer_str).unwrap();
226+
assert!(bob_offer.issuer_signing_pubkey().is_none());
227+
assert_eq!(bob_offer.paths().len(), 1);
228+
229+
let bob_offer_node_id = bob_offer
230+
.paths()
231+
.iter()
232+
.filter_map(|path| path.blinded_hops().last())
233+
.map(|hop| hop.blinded_node_id)
234+
.collect::<Vec<_>>();
235+
let bob_offer_node_id = bob_offer_node_id.first().unwrap();
236+
assert_eq!(
237+
bob_offer_node_id.to_string(),
238+
"035e4d1b7237898390e7999b6835ef83cd93b98200d599d29075b45ab0fedc2b34"
239+
);
240+
241+
let alice_computed = compute_contact_secret(&alice_priv_key, &bob_offer);
242+
let bob_computed = compute_contact_secret(&bob_priv_key, &alice_offer);
243+
244+
assert_eq!(
245+
alice_computed.primary_secret().as_bytes().to_hex_string(bitcoin::hex::Case::Lower),
246+
"810641fab614f8bc1441131dc50b132fd4d1e2ccd36f84b887bbab3a6d8cc3d8".to_owned()
247+
);
248+
assert_eq!(alice_computed, bob_computed);
249+
}
250+
251+
// derive deterministic contact_secret when one offer uses both blinded paths and issuer_id
252+
#[test]
253+
fn test_compute_contact_secret_test_vector_blinded_paths_and_issuer_id() {
254+
let alice_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsrejlwh4vyz70s46r62vtakl4sxztqj6gxjged0wx0ly8qtrygufcsyq5agaes6v605af5rr9ydnj9srneudvrmc73n7evp72tzpqcnd28puqr8a3wmcff9wfjwgk32650vl747m2ev4zsjagzucntctlmcpc6vhmdnxlywneg5caqz0ansr45z2faxq7unegzsnyuduzys7kzyugpwcmhdqqj0h70zy92p75pseunclwsrwhaelvsqy9zsejcytxulndppmykcznn7y5h";
255+
let alice_priv_key =
256+
SecretKey::from_str("4ed1a01dae275f7b7ba503dbae23dddd774a8d5f64788ef7a768ed647dd0e1eb")
257+
.unwrap();
258+
let alice_offer = Offer::from_str(alice_offer_str).unwrap();
259+
260+
assert!(alice_offer.issuer_signing_pubkey().is_none());
261+
assert_eq!(alice_offer.paths().len(), 1);
262+
263+
let alice_offer_node_id = alice_offer
264+
.paths()
265+
.iter()
266+
.filter_map(|path| path.blinded_hops().last())
267+
.map(|hop| hop.blinded_node_id)
268+
.collect::<Vec<_>>();
269+
let alice_offer_node_id = alice_offer_node_id.first().unwrap();
270+
assert_eq!(
271+
alice_offer_node_id.to_string(),
272+
"0284c9c6f04487ac22710176377680127dfcf110aa0fa8186793c7dd01bafdcfd9"
273+
);
274+
275+
let bob_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsz4n88s74qhussxsu0vs3c4unck4yelk67zdc29ree3sztvjn7pc9qyqlcpj54jnj67aa9rd2n5dhjlxyfmv3vgqymrks2nf7gnf5u200mn5qrxfrxh9d0ug43j5egklhwgyrfv3n84gyjd2aajhwqxa0cc7zn37sncrwptz4uhlp523l83xpjx9dw72spzecrtex3ku3h3xpepeuend5rtmurekfmnqsq6kva9yr4k3dtplku9v6qqyxr5ep6lls3hvrqyt9y7htaz9qjzcssy065ctv38c5h03lu0hlvq2t4p5fg6u668y6pmzcg64hmdm050jxx";
276+
let bob_priv_key =
277+
SecretKey::from_str("bcaafa8ed73da11437ce58c7b3458567a870168c0da325a40292fed126b97845")
278+
.unwrap();
279+
let bob_offer = Offer::from_str(bob_offer_str).unwrap();
280+
let bob_offer_node_id = bob_offer.issuer_signing_pubkey().unwrap();
281+
assert_eq!(
282+
bob_offer_node_id.to_string(),
283+
"023f54c2d913e2977c7fc7dfec029750d128d735a39341d8b08d56fb6edf47c8c6"
284+
);
285+
286+
let alice_computed = compute_contact_secret(&alice_priv_key, &bob_offer);
287+
let bob_computed = compute_contact_secret(&bob_priv_key, &alice_offer);
288+
289+
assert_eq!(
290+
alice_computed.primary_secret().as_bytes().to_hex_string(bitcoin::hex::Case::Lower),
291+
"4e0aa72cc42eae9f8dc7c6d2975bbe655683ada2e9abfdfe9f299d391ed9736c".to_owned()
292+
);
293+
assert_eq!(alice_computed, bob_computed);
294+
}
295+
}

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)