Skip to content

Commit e397c77

Browse files
Simplify LSPS5/validator: drop time checks & custom signature storage
Remove timestamp validation, TimeProvider, SignatureStore trait and its InMemory implementation in favor of a fixed size signature cache. HTTPS already secures delivery, so a small cache is sufficient for basic replay protection.
1 parent ff279d6 commit e397c77

File tree

3 files changed

+78
-325
lines changed

3 files changed

+78
-325
lines changed

lightning-liquidity/src/lsps5/msgs.rs

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,6 @@ pub enum LSPS5ClientError {
161161
/// The cryptographic signature from the LSP node doesn't validate.
162162
InvalidSignature,
163163

164-
/// Notification timestamp is too old or too far in the future.
165-
///
166-
/// LSPS5 requires timestamps to be within ±10 minutes of current time.
167-
InvalidTimestamp,
168-
169164
/// Detected a reused notification signature.
170165
///
171166
/// Indicates a potential replay attack where a previously seen
@@ -183,8 +178,7 @@ impl LSPS5ClientError {
183178
use LSPS5ClientError::*;
184179
match self {
185180
InvalidSignature => Self::BASE + 1,
186-
InvalidTimestamp => Self::BASE + 2,
187-
ReplayAttack => Self::BASE + 3,
181+
ReplayAttack => Self::BASE + 2,
188182
SerializationError => LSPS5_SERIALIZATION_ERROR_CODE,
189183
}
190184
}
@@ -193,7 +187,6 @@ impl LSPS5ClientError {
193187
use LSPS5ClientError::*;
194188
match self {
195189
InvalidSignature => "Invalid signature",
196-
InvalidTimestamp => "Timestamp out of range",
197190
ReplayAttack => "Replay attack detected",
198191
SerializationError => "Error serializing LSPS5 webhook notification",
199192
}

lightning-liquidity/src/lsps5/validator.rs

Lines changed: 32 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@
1010
//! LSPS5 Validator
1111
1212
use super::msgs::LSPS5ClientError;
13-
use super::service::TimeProvider;
1413

15-
use crate::alloc::string::ToString;
1614
use crate::lsps0::ser::LSPSDateTime;
1715
use crate::lsps5::msgs::WebhookNotification;
1816
use crate::sync::Mutex;
@@ -24,32 +22,8 @@ use bitcoin::secp256k1::PublicKey;
2422
use alloc::collections::VecDeque;
2523
use alloc::string::String;
2624

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-
}
25+
/// Maximum number of recent signatures to track for replay attack prevention.
26+
pub const MAX_RECENT_SIGNATURES: usize = 5;
5327

5428
/// A utility for validating webhook notifications from an LSP.
5529
///
@@ -60,66 +34,26 @@ impl Default for SignatureStorageConfig {
6034
///
6135
/// # Core Capabilities
6236
///
63-
/// - `validate(...)` -> Verifies signature, timestamp, and protects against replay attacks.
64-
///
65-
/// # Usage
37+
/// - `validate(...)` -> Verifies signature, and protects against replay attacks.
6638
///
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.
39+
/// The validator stores a [`small number`] of the most recently seen signatures
40+
/// to protect against replays of the same notification.
7041
///
42+
/// [`small number`]: MAX_RECENT_SIGNATURES
7143
/// [`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,
44+
pub struct LSPS5Validator {
45+
recent_signatures: Mutex<VecDeque<String>>,
7946
}
8047

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-
}
48+
impl LSPS5Validator {
49+
/// Create a new LSPS5Validator instance.
50+
pub fn new() -> Self {
51+
Self { recent_signatures: Mutex::new(VecDeque::with_capacity(MAX_RECENT_SIGNATURES)) }
11652
}
11753

11854
/// Parse and validate a webhook notification received from an LSP.
11955
///
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.
56+
/// Verifies the webhook delivery by verifying the zbase32 LN-style signature against the LSP's node ID and ensuring that the signature is not a replay of a previously seen notification (within the last [`MAX_RECENT_SIGNATURES`] notifications).
12357
///
12458
/// Call this method on your proxy/server before processing any webhook notification
12559
/// to ensure its authenticity.
@@ -130,91 +64,40 @@ where
13064
/// - `signature`: The zbase32-encoded LN signature over timestamp+body.
13165
/// - `notification`: The [`WebhookNotification`] received from the LSP.
13266
///
133-
/// Returns the validated [`WebhookNotification`] or an error for invalid timestamp,
134-
/// replay attack, or signature verification failure.
67+
/// Returns the validated [`WebhookNotification`] or an error for signature verification failure or replay attack.
13568
///
13669
/// [`WebhookNotification`]: super::msgs::WebhookNotification
70+
/// [`MAX_RECENT_SIGNATURES`]: MAX_RECENT_SIGNATURES
13771
pub fn validate(
13872
&self, counterparty_node_id: PublicKey, timestamp: &LSPSDateTime, signature: &str,
13973
notification: &WebhookNotification,
14074
) -> Result<WebhookNotification, LSPS5ClientError> {
141-
self.verify_notification_signature(
142-
counterparty_node_id,
143-
timestamp,
144-
signature,
145-
notification,
146-
)?;
75+
let notification_json = serde_json::to_string(notification)
76+
.map_err(|_| LSPS5ClientError::SerializationError)?;
77+
let message = format!(
78+
"LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}",
79+
timestamp.to_rfc3339(),
80+
notification_json
81+
);
14782

148-
if self.signature_store.exists(signature)? {
149-
return Err(LSPS5ClientError::ReplayAttack);
83+
if !message_signing::verify(message.as_bytes(), signature, &counterparty_node_id) {
84+
return Err(LSPS5ClientError::InvalidSignature);
15085
}
15186

152-
self.signature_store.store(signature)?;
87+
self.check_for_replay_attack(signature)?;
15388

15489
Ok(notification.clone())
15590
}
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-
}
17591

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-
}
92+
fn check_for_replay_attack(&self, signature: &str) -> Result<(), LSPS5ClientError> {
93+
let mut signatures = self.recent_signatures.lock().unwrap();
94+
if signatures.contains(&signature.to_string()) {
95+
return Err(LSPS5ClientError::ReplayAttack);
20096
}
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);
97+
if signatures.len() == MAX_RECENT_SIGNATURES {
98+
signatures.pop_back();
21799
}
100+
signatures.push_front(signature.to_string());
218101
Ok(())
219102
}
220103
}

0 commit comments

Comments
 (0)