lightning_liquidity/lsps5/validator.rs
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
12use super::msgs::LSPS5ClientError;
13
14use crate::alloc::string::ToString;
15use crate::lsps0::ser::LSPSDateTime;
16use crate::lsps5::msgs::WebhookNotification;
17use crate::sync::Mutex;
18
19use lightning::util::message_signing;
20
21use bitcoin::secp256k1::PublicKey;
22
23use alloc::collections::VecDeque;
24use alloc::string::String;
25
26/// Maximum number of recent signatures to track for replay attack prevention.
27pub const MAX_RECENT_SIGNATURES: usize = 5;
28
29/// A utility for validating webhook notifications from an LSP.
30///
31/// In a typical setup, a proxy server receives webhook notifications from the LSP
32/// and then forwards them to the client (e.g., via mobile push notifications).
33/// This validator should be used by the proxy to verify the authenticity and
34/// integrity of the notification before processing or forwarding it.
35///
36/// # Core Capabilities
37///
38/// - `validate(...)` -> Verifies signature, and protects against replay attacks.
39///
40/// The validator stores a [`small number`] of the most recently seen signatures
41/// to protect against replays of the same notification.
42///
43/// [`small number`]: MAX_RECENT_SIGNATURES
44/// [`bLIP-55 / LSPS5 specification`]: https://github.com/lightning/blips/pull/55/files
45pub struct LSPS5Validator {
46 recent_signatures: Mutex<VecDeque<String>>,
47}
48
49impl LSPS5Validator {
50 /// Create a new LSPS5Validator instance.
51 pub fn new() -> Self {
52 Self { recent_signatures: Mutex::new(VecDeque::with_capacity(MAX_RECENT_SIGNATURES)) }
53 }
54
55 /// Parse and validate a webhook notification received from an LSP.
56 ///
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).
58 ///
59 /// Call this method on your proxy/server before processing any webhook notification
60 /// to ensure its authenticity.
61 ///
62 /// # Parameters
63 /// - `counterparty_node_id`: The LSP's public key, used to verify the signature.
64 /// - `timestamp`: ISO8601 time when the LSP created the notification.
65 /// - `signature`: The zbase32-encoded LN signature over timestamp+body.
66 /// - `notification`: The [`WebhookNotification`] received from the LSP.
67 ///
68 /// Returns the validated [`WebhookNotification`] or an error for signature verification failure or replay attack.
69 ///
70 /// [`WebhookNotification`]: super::msgs::WebhookNotification
71 /// [`MAX_RECENT_SIGNATURES`]: MAX_RECENT_SIGNATURES
72 pub fn validate(
73 &self, counterparty_node_id: PublicKey, timestamp: &LSPSDateTime, signature: &str,
74 notification: &WebhookNotification,
75 ) -> Result<WebhookNotification, LSPS5ClientError> {
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 );
83
84 if !message_signing::verify(message.as_bytes(), signature, &counterparty_node_id) {
85 return Err(LSPS5ClientError::InvalidSignature);
86 }
87
88 self.check_for_replay_attack(signature)?;
89
90 Ok(notification.clone())
91 }
92
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);
97 }
98 if signatures.len() == MAX_RECENT_SIGNATURES {
99 signatures.pop_back();
100 }
101 signatures.push_front(signature.to_string());
102 Ok(())
103 }
104}