Skip to content

Commit abf6329

Browse files
add webhook notification signing utility
- Implement sign_notification to generate a recoverable ECDSA signature in lspsig format. - Format the message per LSPS5 specification and hash using SHA-256. - Serialize the signature with a recovery ID for signature verification. - Add tests to verify correct signature generation and key recovery.
1 parent 1e223ee commit abf6329

File tree

1 file changed

+112
-0
lines changed

1 file changed

+112
-0
lines changed
+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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+
//! Utilities for LSPS5 webhook notifications
11+
12+
use bitcoin::hashes::{sha256, Hash};
13+
use bitcoin::hex::DisplayHex;
14+
use bitcoin::secp256k1::{Secp256k1, SecretKey};
15+
use lightning::ln::msgs::{ErrorAction, LightningError};
16+
use lightning::util::logger::Level;
17+
18+
/// Sign a webhook notification with an LSP's signing key
19+
///
20+
/// This function takes a notification body and timestamp and returns a signature
21+
/// in the format required by the LSPS5 specification.
22+
///
23+
/// # Arguments
24+
///
25+
/// * `body` - The serialized notification JSON
26+
/// * `timestamp` - The ISO8601 timestamp string
27+
/// * `signing_key` - The LSP private key used for signing
28+
///
29+
/// # Returns
30+
///
31+
/// * The signature in "lspsig:{hex}" format, or an error if signing fails
32+
pub fn sign_notification(
33+
body: &str,
34+
timestamp: &str,
35+
signing_key: &SecretKey
36+
) -> Result<String, LightningError> {
37+
// Create the message to sign
38+
// According to spec:
39+
// The message to be signed is: "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At ${timestamp} I notify ${body}"
40+
let message = format!(
41+
"LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}",
42+
timestamp,
43+
body
44+
);
45+
46+
// Hash the message
47+
let message_hash = sha256::Hash::hash(message.as_bytes());
48+
49+
// Sign the message
50+
let secp = Secp256k1::new();
51+
let message_to_sign = bitcoin::secp256k1::Message::from_digest_slice(message_hash.as_ref())
52+
.map_err(|e| LightningError {
53+
err: format!("Failed to create message from digest: {}", e),
54+
action: ErrorAction::IgnoreAndLog(Level::Error),
55+
})?;
56+
57+
let signature = secp.sign_ecdsa_recoverable(&message_to_sign, signing_key);
58+
59+
// Convert signature to lspsig format
60+
let (recovery_id, signature_bytes) = signature.serialize_compact();
61+
let mut signature_with_recovery_id = Vec::with_capacity(65);
62+
signature_with_recovery_id.push(recovery_id.to_i32() as u8);
63+
signature_with_recovery_id.extend_from_slice(&signature_bytes);
64+
65+
Ok(format!("lspsig:{}", signature_with_recovery_id.to_lower_hex_string()))
66+
}
67+
68+
#[cfg(test)]
69+
mod tests {
70+
use super::*;
71+
use bitcoin::{hex::FromHex, secp256k1::Secp256k1};
72+
73+
#[test]
74+
fn test_sign_notification() {
75+
let signing_key = SecretKey::from_slice(&[1; 32]).unwrap();
76+
let body = r#"{"jsonrpc":"2.0","method":"lsps5.webhook_registered","params":{}}"#;
77+
let timestamp = "2023-01-01T12:00:00.000Z";
78+
79+
let signature = sign_notification(body, timestamp, &signing_key).unwrap();
80+
81+
// Verify signature has the correct format
82+
assert!(signature.starts_with("lspsig:"));
83+
assert_eq!(signature.len(), 7 + 130); // "lspsig:" + 65 bytes as hex (130 chars)
84+
85+
// Verify the signature is correct
86+
let secp = Secp256k1::new();
87+
let message = format!(
88+
"LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}",
89+
timestamp,
90+
body
91+
);
92+
let message_hash = sha256::Hash::hash(message.as_bytes());
93+
let message_to_verify = bitcoin::secp256k1::Message::from_digest_slice(message_hash.as_ref()).unwrap();
94+
95+
let signature_data = &signature[7..]; // Remove "lspsig:" prefix
96+
let signature_bytes = Vec::from_hex(signature_data).unwrap();
97+
98+
let recovery_id = bitcoin::secp256k1::ecdsa::RecoveryId::from_i32(signature_bytes[0] as i32).unwrap();
99+
let mut sig_data = [0u8; 64];
100+
sig_data.copy_from_slice(&signature_bytes[1..65]);
101+
102+
let recoverable_sig = bitcoin::secp256k1::ecdsa::RecoverableSignature::from_compact(
103+
&sig_data,
104+
recovery_id,
105+
).unwrap();
106+
107+
let pubkey = secp.recover_ecdsa(&message_to_verify, &recoverable_sig).unwrap();
108+
let expected_pubkey = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &signing_key);
109+
110+
assert_eq!(pubkey, expected_pubkey);
111+
}
112+
}

0 commit comments

Comments
 (0)