1010//! LSPS5 Validator
1111
1212use super :: msgs:: LSPS5ClientError ;
13- use super :: service:: TimeProvider ;
1413
15- use crate :: alloc:: string:: ToString ;
1614use crate :: lsps0:: ser:: LSPSDateTime ;
1715use crate :: lsps5:: msgs:: WebhookNotification ;
1816use crate :: sync:: Mutex ;
@@ -24,32 +22,8 @@ use bitcoin::secp256k1::PublicKey;
2422use alloc:: collections:: VecDeque ;
2523use 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