Skip to content

Commit 923886d

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 923886d

File tree

2 files changed

+299
-0
lines changed

2 files changed

+299
-0
lines changed

lightning/src/offers/contacts.rs

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

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)