Skip to content
5 changes: 4 additions & 1 deletion src/bolt12.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,10 @@ impl Bolt12Wrapper {
pub async fn new_client(
ln_client_config: &lnclient::LNClientConfig,
) -> Result<Arc<Mutex<dyn lnclient::LNClient>>, Box<dyn Error + Send + Sync>> {
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);

Expand Down
5 changes: 4 additions & 1 deletion src/cln.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ impl CLNWrapper {
pub async fn new_client(
ln_client_config: &lnclient::LNClientConfig,
) -> Result<Arc<Mutex<dyn lnclient::LNClient>>, Box<dyn Error + Send + Sync>> {
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);

Expand Down
5 changes: 4 additions & 1 deletion src/eclair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ impl EclairWrapper {
pub async fn new_client(
ln_client_config: &lnclient::LNClientConfig,
) -> Result<Arc<Mutex<dyn lnclient::LNClient>>, Box<dyn Error + Send + Sync>> {
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://") {
Expand Down
107 changes: 100 additions & 7 deletions src/l402.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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)
}
}

Expand All @@ -75,10 +77,21 @@ pub fn verify_l402(
root_key: Vec<u8>,
preimage: PaymentPreimage,
) -> Result<(), Box<dyn std::error::Error>> {
// 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<Vec<u8>> = 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);
Expand Down Expand Up @@ -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
Expand Down
42 changes: 22 additions & 20 deletions src/lnc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,11 +349,12 @@ pub fn parse_pairing_phrase(phrase: &str) -> Result<LNCPairingData, Box<dyn Erro
// Convert mnemonic to entropy bytes
let passphrase_entropy = mnemonic_to_entropy(&words)?;

eprintln!("Passphrase entropy ({} bytes): {}", passphrase_entropy.len(), hex::encode(&passphrase_entropy));
// Never log the entropy — stream ID and SPAKE2 passphrase both derive from it.
eprintln!("Passphrase entropy: {} bytes", passphrase_entropy.len());

// Derive stream ID from passphrase entropy using SHA-512
let stream_id = derive_stream_id(&passphrase_entropy);
eprintln!("Stream ID ({} bytes): {}", stream_id.len(), hex::encode(&stream_id));
eprintln!("Stream ID: {} bytes", stream_id.len());

// Generate a new local keypair for the session
// In a real implementation, this should be persisted and reused
Expand Down Expand Up @@ -381,10 +382,11 @@ pub fn parse_pairing_phrase_from_entropy(entropy_hex: &str) -> Result<LNCPairing
let passphrase_entropy = hex::decode(entropy_hex.trim())
.map_err(|e| format!("Invalid entropy hex: {}", e))?;

eprintln!("Passphrase entropy ({} bytes): {}", passphrase_entropy.len(), hex::encode(&passphrase_entropy));
// Never log the entropy — stream ID and SPAKE2 passphrase both derive from it.
eprintln!("Passphrase entropy: {} bytes", passphrase_entropy.len());

let stream_id = derive_stream_id(&passphrase_entropy);
eprintln!("Stream ID ({} bytes): {}", stream_id.len(), hex::encode(&stream_id));
eprintln!("Stream ID: {} bytes", stream_id.len());

let secp = Secp256k1::new();
let mut secret_bytes = [0u8; 32];
Expand Down Expand Up @@ -468,7 +470,7 @@ impl LNCMailbox {
/// 1. Encrypt 2-byte length header -> 18 bytes (2 + 16 MAC)
/// 2. Encrypt message body -> N + 16 bytes
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
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.")?;
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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());
Expand All @@ -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;
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -2265,7 +2268,6 @@ impl MailboxConnection {
/// Send an encrypted message through the mailbox
pub async fn send_encrypted(&self, data: &[u8]) -> Result<(), Box<dyn Error + Send + Sync>> {
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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 5 additions & 2 deletions src/lnd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,10 @@ impl LNDWrapper {
pub async fn new_client(
ln_client_config: &lnclient::LNClientConfig,
) -> Result<Arc<Mutex<dyn lnclient::LNClient>>, Box<dyn Error + Send + Sync>> {
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 {
Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 17 additions & 7 deletions src/lnurl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,18 @@ pub struct DecodedPR {

impl LnAddressUrlResJson {
pub async fn new_client(ln_client_config: &lnclient::LNClientConfig) -> Result<Arc<Mutex<dyn lnclient::LNClient>>, Box<dyn std::error::Error + Send + Sync>> {
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)))
}
}
Expand All @@ -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::<SignedRawBolt11Invoice>().unwrap()).unwrap();
// `pr` is whatever the remote provider returned — never unwrap it.
let signed = invoice
.parse::<SignedRawBolt11Invoice>()
.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();

Expand Down
4 changes: 3 additions & 1 deletion src/macaroon_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading
Loading