diff --git a/src/bolt12.rs b/src/bolt12.rs index c222c59..6e285c3 100644 --- a/src/bolt12.rs +++ b/src/bolt12.rs @@ -190,7 +190,10 @@ impl Bolt12Wrapper { pub async fn new_client( ln_client_config: &lnclient::LNClientConfig, ) -> Result>, Box> { - let bolt12_options = ln_client_config.bolt12_config.clone().unwrap(); + let bolt12_options = ln_client_config + .bolt12_config + .clone() + .ok_or("LN_CLIENT_TYPE is BOLT12 but bolt12_config is missing")?; println!("BOLT12 client {} with offer {}", bolt12_options.lightning_dir, bolt12_options.offer); diff --git a/src/cln.rs b/src/cln.rs index ceed1f8..430a15f 100644 --- a/src/cln.rs +++ b/src/cln.rs @@ -25,7 +25,10 @@ impl CLNWrapper { pub async fn new_client( ln_client_config: &lnclient::LNClientConfig, ) -> Result>, Box> { - let cln_options = ln_client_config.cln_config.clone().unwrap(); + let cln_options = ln_client_config + .cln_config + .clone() + .ok_or("LN_CLIENT_TYPE is CLN but cln_config is missing")?; println!("CLN client {}", cln_options.lightning_dir); diff --git a/src/eclair.rs b/src/eclair.rs index f892127..3c57f54 100644 --- a/src/eclair.rs +++ b/src/eclair.rs @@ -44,7 +44,10 @@ impl EclairWrapper { pub async fn new_client( ln_client_config: &lnclient::LNClientConfig, ) -> Result>, Box> { - let mut eclair_options = ln_client_config.eclair_config.clone().unwrap(); + let mut eclair_options = ln_client_config + .eclair_config + .clone() + .ok_or("LN_CLIENT_TYPE is ECLAIR but eclair_config is missing")?; // Ensure API URL has a scheme if !eclair_options.api_url.starts_with("http://") && !eclair_options.api_url.starts_with("https://") { diff --git a/src/l402.rs b/src/l402.rs index 1edbe90..7ac6e4e 100644 --- a/src/l402.rs +++ b/src/l402.rs @@ -1,5 +1,5 @@ use lightning::types::payment::{PaymentHash, PaymentPreimage}; -use macaroon::{Macaroon, Verifier, MacaroonKey}; +use macaroon::{Macaroon, Verifier, MacaroonKey, Caveat}; use rocket::{request, Request}; use hex; @@ -64,8 +64,10 @@ fn macaroon_id_matches_payment_hash(id_bytes: &[u8], payment_hash: &PaymentHash) } else if id_bytes.len() == 32 { id_bytes == expected } else { - // Fallback for unexpected identifier lengths: hex substring match. - hex::encode(id_bytes).contains(&hex::encode(expected)) + // Identifiers from other issuers may carry extra bytes around the hash. + // Search the raw bytes, not hex: a hex substring also matches at nibble + // offsets, where the bytes hold no aligned copy of the hash. + id_bytes.windows(32).any(|w| w == expected) } } @@ -75,10 +77,21 @@ pub fn verify_l402( root_key: Vec, preimage: PaymentPreimage, ) -> Result<(), Box> { - // caveat verification - let mac_caveats = mac.first_party_caveats(); - if caveats.len() > mac_caveats.len() { - return Err("Error validating macaroon: Caveats don't match".into()); + // verify() checks macaroon โІ required; nothing checks the reverse, so a + // duplicate can pad the count in place of a missing caveat. + let mac_predicates: Vec> = mac + .first_party_caveats() + .into_iter() + .filter_map(|c| match c { + Caveat::FirstParty(fp) => Some(fp.predicate().0), + _ => None, + }) + .collect(); + + for required in &caveats { + if !mac_predicates.iter().any(|p| p.as_slice() == required.as_bytes()) { + return Err("Error validating macaroon: Caveats don't match".into()); + } } let mac_key = MacaroonKey::generate(&root_key); @@ -120,6 +133,86 @@ mod tests { r#"L402 macaroon="AGIAJEem", invoice="lnbc10n1p""# ); } + + #[test] + fn payment_binding_matches_bytes_not_hex_nibbles() { + use lightning::types::payment::PaymentHash; + + let ph = PaymentHash([0xabu8; 32]); + + // Both exact forms. + assert!(super::macaroon_id_matches_payment_hash(&ph.0, &ph)); + let mut prefixed = vec![0xffu8]; + prefixed.extend_from_slice(&ph.0); + assert!(super::macaroon_id_matches_payment_hash(&prefixed, &ph)); + + // Foreign issuer framing the hash with extra bytes still binds. + let mut framed = vec![0u8; 8]; + framed.extend_from_slice(&ph.0); + framed.extend_from_slice(&[1u8; 4]); + assert!(super::macaroon_id_matches_payment_hash(&framed, &ph)); + + // hex("0a" + "ba"*32) contains hex(ph) at an odd index, yet no aligned + // copy of the hash exists in the bytes. The hex-substring test matched. + let mut nibble = vec![0x0au8]; + nibble.extend_from_slice(&[0xbau8; 32]); + assert!( + !super::macaroon_id_matches_payment_hash(&nibble, &ph), + "nibble-offset hex match must not bind" + ); + } + + #[test] + fn every_required_caveat_must_be_present() { + use lightning::types::payment::{PaymentHash, PaymentPreimage}; + use macaroon::{ByteString, Macaroon, MacaroonKey}; + + let root_key = vec![7u8; 32]; + let key = MacaroonKey::generate(&root_key); + let preimage = PaymentPreimage([0x11u8; 32]); + let payment_hash = PaymentHash::from(preimage); + + let mint = |caveats: &[&str]| { + let mut mac = + Macaroon::create(Some("L402".into()), &key, payment_hash.0.into()).unwrap(); + for c in caveats { + mac.add_first_party_caveat(ByteString::from(*c)); + } + mac + }; + let required = || vec!["Scope = a".to_string(), "Tier = premium".to_string()]; + + // Duplicate pads the count without carrying "Tier = premium". + assert!( + super::verify_l402( + &mint(&["Scope = a", "Scope = a"]), + required(), + root_key.clone(), + preimage + ) + .is_err(), + "duplicate caveat must not satisfy a different required caveat" + ); + + // The honest macaroon still verifies. + assert!(super::verify_l402( + &mint(&["Scope = a", "Tier = premium"]), + required(), + root_key.clone(), + preimage + ) + .is_ok()); + + // This entry point exact-matches only the required set, so any added + // caveat is rejected; attenuation belongs on verify_l402_binding. + assert!(super::verify_l402( + &mint(&["Scope = a", "Tier = premium", "ExpiresAt = 123"]), + required(), + root_key, + preimage + ) + .is_err()); + } } /// Verify an L402 macaroon against a [`RequestBinding`] โ€” the high-level entry diff --git a/src/lnc.rs b/src/lnc.rs index 004a011..4ecb994 100644 --- a/src/lnc.rs +++ b/src/lnc.rs @@ -349,11 +349,12 @@ pub fn parse_pairing_phrase(phrase: &str) -> Result Result 18 bytes (2 + 16 MAC) /// 2. Encrypt message body -> N + 16 bytes pub fn encrypt(&mut self, plaintext: &[u8]) -> Result, Box> { - eprintln!("๐Ÿ”’ Encrypting {} bytes to send: {:02x?}", plaintext.len(), &plaintext[..plaintext.len().min(50)]); + eprintln!("๐Ÿ”’ Encrypting {} bytes to send", plaintext.len()); let cipher = self.send_cipher.as_ref() .ok_or("Send cipher not initialized. Complete the Noise handshake before encrypting.")?; @@ -560,7 +562,7 @@ impl LNCMailbox { let plaintext = cipher.decrypt(nonce, encrypted_body) .map_err(|e| format!("Failed to decrypt body: {}", e))?; - eprintln!("๐Ÿ”“ Decrypted {} bytes from server: {:02x?}", plaintext.len(), &plaintext[..plaintext.len().min(50)]); + eprintln!("๐Ÿ”“ Decrypted {} bytes from server", plaintext.len()); if plaintext.len() != expected_length { return Err(format!( @@ -602,14 +604,14 @@ impl LNCMailbox { eprintln!("โœ… Passphrase stretched"); } - let stream_id_hex = hex::encode(&self.stream_id); let receive_sid = self.get_receive_sid(); let send_sid = self.get_send_sid(); - + + // Never log stream IDs โ€” they let an attacker occupy the mailbox stream. eprintln!("Connecting to mailbox server"); - eprintln!(" Full Stream ID ({} bytes): {}", self.stream_id.len(), stream_id_hex); - eprintln!(" Receive SID (serverโ†’client): {}", hex::encode(&receive_sid)); - eprintln!(" Send SID (clientโ†’server): {}", hex::encode(&send_sid)); + eprintln!(" Full Stream ID: {} bytes", self.stream_id.len()); + eprintln!(" Receive SID: {} bytes", receive_sid.len()); + eprintln!(" Send SID: {} bytes", send_sid.len()); self.connect_to_mailbox().await } @@ -720,7 +722,7 @@ impl LNCMailbox { ); eprintln!("๐Ÿ“ค Sending GoBN SYN to server (clientโ†’server stream)"); - eprintln!(" Stream ID: {}", hex::encode(&send_sid[..])); + eprintln!(" Stream ID: {} bytes", send_sid.len()); if let Err(e) = send_write.send(Message::Text(syn_msg)).await { let _ = send_write.close().await; return Err(format!("Failed to send GoBN SYN: {}", e).into()); @@ -744,7 +746,7 @@ impl LNCMailbox { // Subscribe to the receive stream let recv_init = format!(r#"{{"stream_id":"{}"}}"#, receive_sid_base64); eprintln!("๐Ÿ“ค Subscribing to RECEIVE stream (serverโ†’client)"); - eprintln!(" Stream ID: {}", hex::encode(&receive_sid[..])); + eprintln!(" Stream ID: {} bytes", receive_sid.len()); if let Err(e) = recv_write.send(Message::Text(recv_init)).await { let _ = recv_write.close().await; let _ = send_write.close().await; @@ -981,7 +983,7 @@ impl LNCMailbox { Err(format!("Unexpected response from server: {}", text).into()) } Ok(Message::Binary(data)) => { - eprintln!("๐Ÿ“ฅ Binary response ({} bytes): {:02x?}", data.len(), &data[..data.len().min(20)]); + eprintln!("๐Ÿ“ฅ Binary response ({} bytes)", data.len()); if data.len() >= 2 && data[0] == GBN_MSG_SYN { let server_n = data[1]; @@ -1830,7 +1832,8 @@ impl NoiseHandshakeState { // Store authentication data from Act 2 payload if let Some(payload) = auth_payload { let auth_str = String::from_utf8_lossy(&payload).to_string(); - eprintln!("๐Ÿ” Received authentication data in Act 2: {}", auth_str); + // Never log auth_str โ€” it is the session credential sent as gRPC metadata. + eprintln!("๐Ÿ” Received authentication data in Act 2 ({} bytes)", auth_str.len()); self.auth_data = Some(auth_str); } @@ -2265,7 +2268,6 @@ impl MailboxConnection { /// Send an encrypted message through the mailbox pub async fn send_encrypted(&self, data: &[u8]) -> Result<(), Box> { eprintln!("๐Ÿ”’ Encrypting {} bytes for transmission", data.len()); - eprintln!(" First 20 bytes (plaintext): {:02x?}", &data[..data.len().min(20)]); let mut mailbox = self.mailbox.lock().await; // Encrypt with Noise cipher @@ -2295,7 +2297,7 @@ impl MailboxConnection { let mut mailbox = self.mailbox.lock().await; let decrypted = mailbox.decrypt(&noise_msg)?; - eprintln!("โœ… Decrypted to {} bytes: {:02x?}", decrypted.len(), &decrypted[..decrypted.len().min(50)]); + eprintln!("โœ… Decrypted to {} bytes", decrypted.len()); Ok(decrypted) } @@ -2393,7 +2395,7 @@ impl tokio::io::AsyncRead for MailboxConnection { return Ok(()); } - eprintln!("๐Ÿ“ฅ Received {} bytes of encrypted Noise data: {:02x?}", noise_encrypted.len(), &noise_encrypted[..noise_encrypted.len().min(20)]); + eprintln!("๐Ÿ“ฅ Received {} bytes of encrypted Noise data", noise_encrypted.len()); // Add to encrypted buffer let mut enc_buf = encrypted_buf_arc.lock().await; @@ -2468,7 +2470,7 @@ impl tokio::io::AsyncRead for MailboxConnection { } else { // Real decryption error - could be connection closing or corrupted data eprintln!(" โŒ Decryption error: {}", e); - eprintln!(" ๐Ÿ“Š Encrypted buffer contents ({} bytes): {:02x?}", enc_buf.len(), &enc_buf[..enc_buf.len().min(50)]); + eprintln!(" ๐Ÿ“Š Encrypted buffer: {} bytes", enc_buf.len()); eprintln!(" ๐Ÿ”ข Buffer length: {}, Nonce before: {}, Nonce after: {}", enc_buf_len_before, recv_nonce_before, mailbox_guard.recv_nonce); // Check if this might be a connection close or error message @@ -2618,8 +2620,8 @@ fn parse_settings_frame(payload: &[u8], flags: u8) { } fn parse_headers_frame(payload: &[u8], flags: u8) { + // Never log HEADERS contents โ€” they carry the gRPC session auth credential. eprintln!(" ๐Ÿ“จ HEADERS frame payload: {} bytes, flags=0x{:02x}", payload.len(), flags); - eprintln!(" ๐Ÿ“จ First 50 bytes: {:02x?}", &payload[..payload.len().min(50)]); // Try to find recognizable patterns if let Ok(s) = std::str::from_utf8(payload) { diff --git a/src/lnd.rs b/src/lnd.rs index 6a332fd..e6f878d 100644 --- a/src/lnd.rs +++ b/src/lnd.rs @@ -175,7 +175,10 @@ impl LNDWrapper { pub async fn new_client( ln_client_config: &lnclient::LNClientConfig, ) -> Result>, Box> { - let lnd_options = ln_client_config.lnd_config.clone().unwrap(); + let lnd_options = ln_client_config + .lnd_config + .clone() + .ok_or("LN_CLIENT_TYPE is LND but lnd_config is missing")?; // Check if LNC pairing phrase is provided let connection = if let Some(pairing_phrase) = &lnd_options.lnc_pairing_phrase { @@ -361,8 +364,8 @@ impl LNDWrapper { && trimmed.chars().all(|c| c.is_ascii_hexdigit()); let pairing_data = if is_hex { + // Never log `trimmed` โ€” it is the raw LNC pairing entropy. eprintln!("Detected entropy hex format, parsing directly..."); - eprintln!("Entropy hex: {}", trimmed); // It's a hex string - use entropy directly lnc::parse_pairing_phrase_from_entropy(trimmed)? } else { diff --git a/src/lnurl.rs b/src/lnurl.rs index be81de5..033f995 100644 --- a/src/lnurl.rs +++ b/src/lnurl.rs @@ -53,13 +53,18 @@ pub struct DecodedPR { impl LnAddressUrlResJson { pub async fn new_client(ln_client_config: &lnclient::LNClientConfig) -> Result>, Box> { - let lnurl_options = ln_client_config.lnurl_config.clone().unwrap(); + let lnurl_options = ln_client_config + .lnurl_config + .clone() + .ok_or("LN_CLIENT_TYPE is LNURL but lnurl_config is missing")?; let (username, domain) = utils::parse_ln_address(lnurl_options.address)?; - - let ln_address_url = format!("https://{}/.well-known/lnurlp/{}", domain, username); - let ln_address_url_res_body = do_get_request(&ln_address_url).await; - - let ln_address_url_res: LnAddressUrlResJson = serde_json::from_str(&ln_address_url_res_body.unwrap())?; + + // LUD-16: onion services are served over http; everything else https. + let scheme = if domain.ends_with(".onion") { "http" } else { "https" }; + let ln_address_url = format!("{}://{}/.well-known/lnurlp/{}", scheme, domain, username); + let ln_address_url_res_body = do_get_request(&ln_address_url).await?; + + let ln_address_url_res: LnAddressUrlResJson = serde_json::from_str(&ln_address_url_res_body)?; Ok(Arc::new(Mutex::new(ln_address_url_res))) } } @@ -82,7 +87,12 @@ impl lnclient::LNClient for LnAddressUrlResJson { serde_json::from_str(&callback_url_res_body)?; let invoice = callback_url_res_json.pr; - let decoded_invoice = Bolt11Invoice::from_signed(invoice.parse::().unwrap()).unwrap(); + // `pr` is whatever the remote provider returned โ€” never unwrap it. + let signed = invoice + .parse::() + .map_err(|e| format!("LNURL callback returned an unparsable invoice: {:?}", e))?; + let decoded_invoice = Bolt11Invoice::from_signed(signed) + .map_err(|e| format!("LNURL callback invoice failed validation: {:?}", e))?; let payment_hash = decoded_invoice.payment_hash(); let payment_addr = decoded_invoice.payment_secret(); diff --git a/src/macaroon_util.rs b/src/macaroon_util.rs index 2bbb84b..2b43a8f 100644 --- a/src/macaroon_util.rs +++ b/src/macaroon_util.rs @@ -19,7 +19,9 @@ pub fn get_macaroon_as_string( mac.add_first_party_caveat(ByteString::from(caveat.as_str())); } - let macaroon_string = mac.serialize(Format::V1).unwrap(); + // V1 length-prefixes each packet with 4 hex digits, so a caveat over 65535 + // bytes fails to serialize. + let macaroon_string = mac.serialize(Format::V1)?; Ok(macaroon_string) } diff --git a/src/nwc.rs b/src/nwc.rs index f261d2a..7d6fb33 100644 --- a/src/nwc.rs +++ b/src/nwc.rs @@ -19,7 +19,10 @@ pub struct NWCWrapper { impl NWCWrapper { pub async fn new_client(ln_client_config: &lnclient::LNClientConfig) -> Result>, Box> { - let nwc_options = ln_client_config.nwc_config.clone().unwrap(); + let nwc_options = ln_client_config + .nwc_config + .clone() + .ok_or("LN_CLIENT_TYPE is NWC but nwc_config is missing")?; let uri = NostrWalletConnectURI::parse(&nwc_options.uri)?; let nwc = NWC::new(uri); Ok(Arc::new(Mutex::new(NWCWrapper { client: Arc::new(Mutex::new(nwc)) }))) @@ -45,7 +48,13 @@ impl lnclient::LNClient for NWCWrapper { Ok(res) => { println!("response {:?}", res); - let decoded_invoice = Bolt11Invoice::from_signed(res.invoice.parse::().unwrap()).unwrap(); + // res.invoice comes from the remote wallet โ€” never unwrap it. + let signed = res + .invoice + .parse::() + .map_err(|e| format!("NWC returned an unparsable invoice: {:?}", e))?; + let decoded_invoice = Bolt11Invoice::from_signed(signed) + .map_err(|e| format!("NWC invoice failed validation: {:?}", e))?; let payment_addr = decoded_invoice.payment_secret(); // payment_hash is optional in the NIP-47 response. let payment_hash = res diff --git a/src/utils.rs b/src/utils.rs index 94bfab3..dea3d87 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -40,6 +40,28 @@ pub fn parse_ln_address(address: String) -> Result<(String, String), String> { let username = address_split[0].to_string(); let domain = address_split[1].to_string(); + // Both halves are interpolated into https://{domain}/.well-known/lnurlp/{username} + // and fetched, so anything that can restructure that URL โ€” '/', '?', '#', ':', + // '..' โ€” has to be rejected here. LUD-16 limits the username to a-z0-9-_. and + // the domain is a hostname, so the allowed sets are narrow. + let username_ok = !username.is_empty() + && username != ".." + && username + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')); + if !username_ok { + return Err("Invalid lightning address username".to_string()); + } + + let domain_ok = !domain.is_empty() + && !domain.contains("..") + && domain + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.')); + if !domain_ok { + return Err("Invalid lightning address domain".to_string()); + } + Ok((username, domain)) } @@ -73,3 +95,55 @@ pub fn get_preimage_from_string(preimage_string: String) -> Result