10
10
//! LSPS5 Validator
11
11
12
12
use super :: msgs:: LSPS5ClientError ;
13
- use super :: service:: TimeProvider ;
14
13
15
- use crate :: alloc:: string:: ToString ;
16
14
use crate :: lsps0:: ser:: LSPSDateTime ;
17
15
use crate :: lsps5:: msgs:: WebhookNotification ;
18
16
use crate :: sync:: Mutex ;
@@ -24,32 +22,8 @@ use bitcoin::secp256k1::PublicKey;
24
22
use alloc:: collections:: VecDeque ;
25
23
use alloc:: string:: String ;
26
24
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 ;
53
27
54
28
/// A utility for validating webhook notifications from an LSP.
55
29
///
@@ -60,66 +34,26 @@ impl Default for SignatureStorageConfig {
60
34
///
61
35
/// # Core Capabilities
62
36
///
63
- /// - `validate(...)` -> Verifies signature, timestamp, and protects against replay attacks.
64
- ///
65
- /// # Usage
37
+ /// - `validate(...)` -> Verifies signature, and protects against replay attacks.
66
38
///
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.
70
41
///
42
+ /// [`small number`]: MAX_RECENT_SIGNATURES
71
43
/// [`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 > > ,
79
46
}
80
47
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 ) ) }
116
52
}
117
53
118
54
/// Parse and validate a webhook notification received from an LSP.
119
55
///
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).
123
57
///
124
58
/// Call this method on your proxy/server before processing any webhook notification
125
59
/// to ensure its authenticity.
@@ -130,91 +64,40 @@ where
130
64
/// - `signature`: The zbase32-encoded LN signature over timestamp+body.
131
65
/// - `notification`: The [`WebhookNotification`] received from the LSP.
132
66
///
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.
135
68
///
136
69
/// [`WebhookNotification`]: super::msgs::WebhookNotification
70
+ /// [`MAX_RECENT_SIGNATURES`]: MAX_RECENT_SIGNATURES
137
71
pub fn validate (
138
72
& self , counterparty_node_id : PublicKey , timestamp : & LSPSDateTime , signature : & str ,
139
73
notification : & WebhookNotification ,
140
74
) -> 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
+ ) ;
147
82
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 ) ;
150
85
}
151
86
152
- self . signature_store . store ( signature) ?;
87
+ self . check_for_replay_attack ( signature) ?;
153
88
154
89
Ok ( notification. clone ( ) )
155
90
}
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
91
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 ) ;
200
96
}
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 ( ) ;
217
99
}
100
+ signatures. push_front ( signature. to_string ( ) ) ;
218
101
Ok ( ( ) )
219
102
}
220
103
}
0 commit comments