@@ -15,7 +15,6 @@ use crate::alloc::string::ToString;
1515use crate :: lsps0:: ser:: LSPSDateTime ;
1616use crate :: lsps5:: msgs:: WebhookNotification ;
1717use crate :: sync:: Mutex ;
18- use crate :: utils:: time:: TimeProvider ;
1918
2019use lightning:: util:: message_signing;
2120
@@ -24,32 +23,8 @@ use bitcoin::secp256k1::PublicKey;
2423use alloc:: collections:: VecDeque ;
2524use 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