|
| 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