|
| 1 | +// This file is Copyright its original authors, visible in version control |
| 2 | +// history. |
| 3 | +// |
| 4 | +// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE |
| 5 | +// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license |
| 6 | +// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option. |
| 7 | +// You may not use this file except in accordance with one or both of these |
| 8 | +// licenses. |
| 9 | + |
| 10 | +//! LSPS5 Validator |
| 11 | +
|
| 12 | +use super::msgs::LSPS5ClientError; |
| 13 | +use super::service::TimeProvider; |
| 14 | + |
| 15 | +use crate::alloc::string::ToString; |
| 16 | +use crate::lsps0::ser::LSPSDateTime; |
| 17 | +use crate::lsps5::msgs::WebhookNotification; |
| 18 | +use crate::sync::Mutex; |
| 19 | + |
| 20 | +use lightning::util::message_signing; |
| 21 | + |
| 22 | +use bitcoin::secp256k1::PublicKey; |
| 23 | + |
| 24 | +use alloc::collections::VecDeque; |
| 25 | +use alloc::string::String; |
| 26 | + |
| 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 | +} |
| 53 | + |
| 54 | +/// A utility for validating webhook notifications from an LSP. |
| 55 | +/// |
| 56 | +/// In a typical setup, a proxy server receives webhook notifications from the LSP |
| 57 | +/// and then forwards them to the client (e.g., via mobile push notifications). |
| 58 | +/// This validator should be used by the proxy to verify the authenticity and |
| 59 | +/// integrity of the notification before processing or forwarding it. |
| 60 | +/// |
| 61 | +/// # Core Capabilities |
| 62 | +/// |
| 63 | +/// - `validate(...)` -> Verifies signature, timestamp, and protects against replay attacks. |
| 64 | +/// |
| 65 | +/// # Usage |
| 66 | +/// |
| 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. |
| 70 | +/// |
| 71 | +/// [`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, |
| 79 | +} |
| 80 | + |
| 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 | + } |
| 116 | + } |
| 117 | + |
| 118 | + /// Parse and validate a webhook notification received from an LSP. |
| 119 | + /// |
| 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. |
| 123 | + /// |
| 124 | + /// Call this method on your proxy/server before processing any webhook notification |
| 125 | + /// to ensure its authenticity. |
| 126 | + /// |
| 127 | + /// # Parameters |
| 128 | + /// - `counterparty_node_id`: The LSP's public key, used to verify the signature. |
| 129 | + /// - `timestamp`: ISO8601 time when the LSP created the notification. |
| 130 | + /// - `signature`: The zbase32-encoded LN signature over timestamp+body. |
| 131 | + /// - `notification`: The [`WebhookNotification`] received from the LSP. |
| 132 | + /// |
| 133 | + /// Returns the validated [`WebhookNotification`] or an error for invalid timestamp, |
| 134 | + /// replay attack, or signature verification failure. |
| 135 | + /// |
| 136 | + /// [`WebhookNotification`]: super::msgs::WebhookNotification |
| 137 | + pub fn validate( |
| 138 | + &self, counterparty_node_id: PublicKey, timestamp: &LSPSDateTime, signature: &str, |
| 139 | + notification: &WebhookNotification, |
| 140 | + ) -> Result<WebhookNotification, LSPS5ClientError> { |
| 141 | + self.verify_notification_signature( |
| 142 | + counterparty_node_id, |
| 143 | + timestamp, |
| 144 | + signature, |
| 145 | + notification, |
| 146 | + )?; |
| 147 | + |
| 148 | + if self.signature_store.exists(signature)? { |
| 149 | + return Err(LSPS5ClientError::ReplayAttack); |
| 150 | + } |
| 151 | + |
| 152 | + self.signature_store.store(signature)?; |
| 153 | + |
| 154 | + Ok(notification.clone()) |
| 155 | + } |
| 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 | + |
| 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 | + } |
| 200 | + } |
| 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); |
| 217 | + } |
| 218 | + Ok(()) |
| 219 | + } |
| 220 | +} |
0 commit comments