Skip to content

Commit dc97b9c

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 55d8666 commit dc97b9c

File tree

3 files changed

+78
-324
lines changed

3 files changed

+78
-324
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 & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ use crate::alloc::string::ToString;
1515
use crate::lsps0::ser::LSPSDateTime;
1616
use crate::lsps5::msgs::WebhookNotification;
1717
use crate::sync::Mutex;
18-
use crate::utils::time::TimeProvider;
1918

2019
use lightning::util::message_signing;
2120

@@ -24,32 +23,8 @@ use bitcoin::secp256k1::PublicKey;
2423
use alloc::collections::VecDeque;
2524
use alloc::string::String;
2625

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

5429
/// A utility for validating webhook notifications from an LSP.
5530
///
@@ -60,66 +35,26 @@ impl Default for SignatureStorageConfig {
6035
///
6136
/// # Core Capabilities
6237
///
63-
/// - `validate(...)` -> Verifies signature, timestamp, and protects against replay attacks.
64-
///
65-
/// # Usage
38+
/// - `validate(...)` -> Verifies signature, and protects against replay attacks.
6639
///
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.
40+
/// The validator stores a [`small number`] of the most recently seen signatures
41+
/// to protect against replays of the same notification.
7042
///
43+
/// [`small number`]: MAX_RECENT_SIGNATURES
7144
/// [`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,
45+
pub struct LSPS5Validator {
46+
recent_signatures: Mutex<VecDeque<String>>,
7947
}
8048

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

11855
/// Parse and validate a webhook notification received from an LSP.
11956
///
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.
57+
/// 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).
12358
///
12459
/// Call this method on your proxy/server before processing any webhook notification
12560
/// to ensure its authenticity.
@@ -130,91 +65,40 @@ where
13065
/// - `signature`: The zbase32-encoded LN signature over timestamp+body.
13166
/// - `notification`: The [`WebhookNotification`] received from the LSP.
13267
///
133-
/// Returns the validated [`WebhookNotification`] or an error for invalid timestamp,
134-
/// replay attack, or signature verification failure.
68+
/// Returns the validated [`WebhookNotification`] or an error for signature verification failure or replay attack.
13569
///
13670
/// [`WebhookNotification`]: super::msgs::WebhookNotification
71+
/// [`MAX_RECENT_SIGNATURES`]: MAX_RECENT_SIGNATURES
13772
pub fn validate(
13873
&self, counterparty_node_id: PublicKey, timestamp: &LSPSDateTime, signature: &str,
13974
notification: &WebhookNotification,
14075
) -> Result<WebhookNotification, LSPS5ClientError> {
141-
self.verify_notification_signature(
142-
counterparty_node_id,
143-
timestamp,
144-
signature,
145-
notification,
146-
)?;
76+
let notification_json = serde_json::to_string(notification)
77+
.map_err(|_| LSPS5ClientError::SerializationError)?;
78+
let message = format!(
79+
"LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}",
80+
timestamp.to_rfc3339(),
81+
notification_json
82+
);
14783

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

152-
self.signature_store.store(signature)?;
88+
self.check_for_replay_attack(signature)?;
15389

15490
Ok(notification.clone())
15591
}
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-
}
17592

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-
}
93+
fn check_for_replay_attack(&self, signature: &str) -> Result<(), LSPS5ClientError> {
94+
let mut signatures = self.recent_signatures.lock().unwrap();
95+
if signatures.contains(&signature.to_string()) {
96+
return Err(LSPS5ClientError::ReplayAttack);
20097
}
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);
98+
if signatures.len() == MAX_RECENT_SIGNATURES {
99+
signatures.pop_back();
217100
}
101+
signatures.push_front(signature.to_string());
218102
Ok(())
219103
}
220104
}

0 commit comments

Comments
 (0)