Skip to content

Commit a744797

Browse files
Introduce LSPS5/Validator, a utility for validating webhook notifications from an LSP.
As context, this utility started as part of the client, but was extracted in favor of having it separated, so it's clear that this functions should not be used by the client, but by the proxy server that has to forward the notifications
1 parent 867f12b commit a744797

File tree

1 file changed

+220
-0
lines changed

1 file changed

+220
-0
lines changed
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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+
//! LSPS5 Validator
11+
12+
use super::msgs::LSPS5ClientError;
13+
use super::service::TimeProvider;
14+
15+
use crate::alloc::string::ToString;
16+
use crate::lsps0::ser::LSPSDateTime;
17+
use crate::lsps5::msgs::WebhookNotification;
18+
use crate::sync::Mutex;
19+
20+
use lightning::util::message_signing;
21+
22+
use bitcoin::secp256k1::PublicKey;
23+
24+
use alloc::collections::VecDeque;
25+
use alloc::string::String;
26+
27+
use core::ops::Deref;
28+
use core::time::Duration;
29+
30+
/// Configuration for signature storage.
31+
#[derive(Clone, Copy, Debug)]
32+
pub struct SignatureStorageConfig {
33+
/// Maximum number of signatures to store.
34+
pub max_signatures: usize,
35+
/// Retention time for signatures in minutes.
36+
pub retention_minutes: Duration,
37+
}
38+
39+
/// Default retention time for signatures in minutes (LSPS5 spec requires min 20 minutes).
40+
pub const DEFAULT_SIGNATURE_RETENTION_MINUTES: u64 = 20;
41+
42+
/// Default maximum number of stored signatures.
43+
pub const DEFAULT_MAX_SIGNATURES: usize = 1000;
44+
45+
impl Default for SignatureStorageConfig {
46+
fn default() -> Self {
47+
Self {
48+
max_signatures: DEFAULT_MAX_SIGNATURES,
49+
retention_minutes: Duration::from_secs(DEFAULT_SIGNATURE_RETENTION_MINUTES * 60),
50+
}
51+
}
52+
}
53+
54+
/// A utility for validating webhook notifications from an LSP.
55+
///
56+
/// In a typical setup, a proxy server receives webhook notifications from the LSP
57+
/// and then forwards them to the client (e.g., via mobile push notifications).
58+
/// This validator should be used by the proxy to verify the authenticity and
59+
/// integrity of the notification before processing or forwarding it.
60+
///
61+
/// # Core Capabilities
62+
///
63+
/// - `validate(...)` -> Verifies signature, timestamp, and protects against replay attacks.
64+
///
65+
/// # Usage
66+
///
67+
/// The validator requires a `SignatureStore` to track recently seen signatures
68+
/// to prevent replay attacks. You should create a single `LSPS5Validator` instance
69+
/// and share it across all requests.
70+
///
71+
/// [`bLIP-55 / LSPS5 specification`]: https://github.com/lightning/blips/pull/55/files
72+
pub struct LSPS5Validator<TP: Deref, SS: Deref>
73+
where
74+
TP::Target: TimeProvider,
75+
SS::Target: SignatureStore,
76+
{
77+
time_provider: TP,
78+
signature_store: SS,
79+
}
80+
81+
impl<TP: Deref, SS: Deref> LSPS5Validator<TP, SS>
82+
where
83+
TP::Target: TimeProvider,
84+
SS::Target: SignatureStore,
85+
{
86+
/// Creates a new `LSPS5Validator`.
87+
pub fn new(time_provider: TP, signature_store: SS) -> Self {
88+
Self { time_provider, signature_store }
89+
}
90+
91+
fn verify_notification_signature(
92+
&self, counterparty_node_id: PublicKey, signature_timestamp: &LSPSDateTime,
93+
signature: &str, notification: &WebhookNotification,
94+
) -> Result<(), LSPS5ClientError> {
95+
let now =
96+
LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch());
97+
let diff = signature_timestamp.abs_diff(&now);
98+
const MAX_TIMESTAMP_DRIFT_SECS: u64 = 600;
99+
if diff > MAX_TIMESTAMP_DRIFT_SECS {
100+
return Err(LSPS5ClientError::InvalidTimestamp);
101+
}
102+
103+
let notification_json = serde_json::to_string(notification)
104+
.map_err(|_| LSPS5ClientError::SerializationError)?;
105+
let message = format!(
106+
"LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}",
107+
signature_timestamp.to_rfc3339(),
108+
notification_json
109+
);
110+
111+
if message_signing::verify(message.as_bytes(), signature, &counterparty_node_id) {
112+
Ok(())
113+
} else {
114+
Err(LSPS5ClientError::InvalidSignature)
115+
}
116+
}
117+
118+
/// Parse and validate a webhook notification received from an LSP.
119+
///
120+
/// Verifies the webhook delivery by checking the timestamp is within ±10 minutes,
121+
/// ensuring no signature replay within the retention window, and verifying the
122+
/// zbase32 LN-style signature against the LSP's node ID.
123+
///
124+
/// Call this method on your proxy/server before processing any webhook notification
125+
/// to ensure its authenticity.
126+
///
127+
/// # Parameters
128+
/// - `counterparty_node_id`: The LSP's public key, used to verify the signature.
129+
/// - `timestamp`: ISO8601 time when the LSP created the notification.
130+
/// - `signature`: The zbase32-encoded LN signature over timestamp+body.
131+
/// - `notification`: The [`WebhookNotification`] received from the LSP.
132+
///
133+
/// Returns the validated [`WebhookNotification`] or an error for invalid timestamp,
134+
/// replay attack, or signature verification failure.
135+
///
136+
/// [`WebhookNotification`]: super::msgs::WebhookNotification
137+
pub fn validate(
138+
&self, counterparty_node_id: PublicKey, timestamp: &LSPSDateTime, signature: &str,
139+
notification: &WebhookNotification,
140+
) -> Result<WebhookNotification, LSPS5ClientError> {
141+
self.verify_notification_signature(
142+
counterparty_node_id,
143+
timestamp,
144+
signature,
145+
notification,
146+
)?;
147+
148+
if self.signature_store.exists(signature)? {
149+
return Err(LSPS5ClientError::ReplayAttack);
150+
}
151+
152+
self.signature_store.store(signature)?;
153+
154+
Ok(notification.clone())
155+
}
156+
}
157+
158+
/// Trait for storing and checking webhook notification signatures to prevent replay attacks.
159+
pub trait SignatureStore {
160+
/// Checks if a signature already exists in the store.
161+
fn exists(&self, signature: &str) -> Result<bool, LSPS5ClientError>;
162+
/// Stores a new signature.
163+
fn store(&self, signature: &str) -> Result<(), LSPS5ClientError>;
164+
}
165+
166+
/// An in-memory store for webhook notification signatures.
167+
pub struct InMemorySignatureStore<TP: Deref>
168+
where
169+
TP::Target: TimeProvider,
170+
{
171+
recent_signatures: Mutex<VecDeque<(String, LSPSDateTime)>>,
172+
config: SignatureStorageConfig,
173+
time_provider: TP,
174+
}
175+
176+
impl<TP: Deref> InMemorySignatureStore<TP>
177+
where
178+
TP::Target: TimeProvider,
179+
{
180+
/// Creates a new `InMemorySignatureStore`.
181+
pub fn new(config: SignatureStorageConfig, time_provider: TP) -> Self {
182+
Self {
183+
recent_signatures: Mutex::new(VecDeque::with_capacity(config.max_signatures)),
184+
config,
185+
time_provider,
186+
}
187+
}
188+
}
189+
190+
impl<TP: Deref> SignatureStore for InMemorySignatureStore<TP>
191+
where
192+
TP::Target: TimeProvider,
193+
{
194+
fn exists(&self, signature: &str) -> Result<bool, LSPS5ClientError> {
195+
let recent_signatures = self.recent_signatures.lock().unwrap();
196+
for (stored_sig, _) in recent_signatures.iter() {
197+
if stored_sig == signature {
198+
return Ok(true);
199+
}
200+
}
201+
Ok(false)
202+
}
203+
204+
fn store(&self, signature: &str) -> Result<(), LSPS5ClientError> {
205+
let now =
206+
LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch());
207+
let mut recent_signatures = self.recent_signatures.lock().unwrap();
208+
209+
recent_signatures.push_back((signature.to_string(), now.clone()));
210+
211+
let retention_secs = self.config.retention_minutes.as_secs();
212+
recent_signatures.retain(|(_, ts)| now.abs_diff(ts) <= retention_secs);
213+
214+
if recent_signatures.len() > self.config.max_signatures {
215+
let excess = recent_signatures.len() - self.config.max_signatures;
216+
recent_signatures.drain(0..excess);
217+
}
218+
Ok(())
219+
}
220+
}

0 commit comments

Comments
 (0)