Skip to content

Commit 5685c51

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 015ace8 commit 5685c51

File tree

1 file changed

+111
-0
lines changed

1 file changed

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

0 commit comments

Comments
 (0)