Skip to content

Commit b8aaab5

Browse files
committed
Add signed fee-policy claim verification
MDK-980 lets a registering node carry a signed grant for a non-standard FeePolicy that the LSP verifies locally before honouring. This is just the pure core: the claim ADT and the verifier. Nothing calls it yet, so behaviour is unchanged. A claim is a versioned TLV pair. ClaimPayload binds {scheme, node_id, policy}; SignedFeeClaim wraps the encoded payload as an opaque byte string plus a detached BIP340 signature over SHA256 of those bytes. Keeping the payload opaque means the verifier hashes exactly what was signed and never reproduces the payload's TLV layout to check the signature. TLV rather than a fixed concatenation keeps later schemes (more policy arms, an issuer key-id) additive: a new tag, not a re-issue. verify_claim takes a slice of issuer keys, not a single Option. An empty slice rejects every claim, which is how the feature stays inert until a key is configured. A non-empty slice accepts a signature from any one of the keys. That covers a no-key-id claim today and key rotation later, and per-key scoping can slot in without a breaking config change. The verifier borrows a caller-supplied secp context rather than building its own, so the verifier stays allocation free and context lifetime is the caller's call. The handler wiring that follows will own a long-lived verify context on the service and hand it in. The scheme byte is read and matched before the signature check because it selects which verification rules apply, so an unknown scheme is rejected before any crypto runs. The wire format is the contract MDK-981 (ldk-node) and MDK-982 (the TS minter) must reproduce byte-for-byte, so an in-tree test vector pins a known issuer key and claim hex. Drift in the TLV layout or the signing input fails that test instead of silently diverging across repos. The signing helper is test-only; the verifier does no I/O.
1 parent a234a10 commit b8aaab5

2 files changed

Lines changed: 314 additions & 0 deletions

File tree

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
// This file is Copyright its original authors, visible in version control history.
2+
//
3+
// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4+
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
5+
// http://opensource.org/licenses/MIT>, at your option. You may not use this file except in
6+
// accordance with one or both of these licenses.
7+
8+
//! Signed fee-policy claims presented by a registering node.
9+
//!
10+
//! A node may carry a claim that grants it a non-standard [`FeePolicy`] (for example a zero-fee
11+
//! grant for funding we do not skim). The claim is minted off-band by an issuer the LSP trusts and
12+
//! presented verbatim in `register_node`; the LSP verifies it locally against its configured issuer
13+
//! keys, with no network I/O. With no issuer key configured every claim is rejected, so every peer
14+
//! falls back to `Flat(Standard)` and behaviour is identical to a node that never sees a claim.
15+
//!
16+
//! The wire format is versioned and TLV-encoded rather than a fixed concatenation, so later schemes
17+
//! (more policy arms, an issuer key-id, per-key scope) are additive: a new tag, not a re-issue.
18+
//! The signed bytes are the *opaque* encoded [`ClaimPayload`], carried as a byte string inside
19+
//! [`SignedFeeClaim`]. The verifier hashes exactly those bytes, so it never has to reproduce the
20+
//! payload's TLV layout to check the signature — it only re-reads the payload after the signature
21+
//! has already covered it.
22+
23+
use bitcoin::hashes::{sha256, Hash};
24+
use bitcoin::hex::FromHex;
25+
use bitcoin::secp256k1::{schnorr, Message, PublicKey, Secp256k1, Verification, XOnlyPublicKey};
26+
27+
use lightning::impl_writeable_tlv_based;
28+
use lightning::util::ser::{Readable, Writeable};
29+
30+
use std::vec::Vec;
31+
32+
use crate::lsps4::fee_policy::FeePolicy;
33+
34+
/// The only signature scheme this version understands: a BIP340 Schnorr signature over secp256k1.
35+
const CLAIM_SCHEME_V1: u8 = 1;
36+
37+
/// The signed half of a claim: the issuer commits to *this node* getting *this policy*.
38+
///
39+
/// Encoded and hashed as an opaque byte string by [`SignedFeeClaim`]; the verifier re-reads it only
40+
/// after the signature has been checked over those exact bytes.
41+
#[derive(Clone, Debug, PartialEq, Eq)]
42+
struct ClaimPayload {
43+
/// Signature scheme tag; only [`CLAIM_SCHEME_V1`] is accepted.
44+
scheme: u8,
45+
/// The node the grant is bound to. Must equal the registering peer's id.
46+
node_id: PublicKey,
47+
/// The policy the issuer is granting.
48+
policy: FeePolicy,
49+
}
50+
51+
impl_writeable_tlv_based!(ClaimPayload, {
52+
(0, scheme, required),
53+
(2, node_id, required),
54+
(4, policy, required),
55+
});
56+
57+
/// The wire container: an opaque [`ClaimPayload`] blob plus the issuer's detached signature over it.
58+
#[derive(Clone, Debug, PartialEq, Eq)]
59+
struct SignedFeeClaim {
60+
/// The encoded [`ClaimPayload`]. Kept as bytes so the signature is verified over exactly what
61+
/// was signed, independent of how this verifier would re-serialize the payload.
62+
payload: Vec<u8>,
63+
/// BIP340 Schnorr signature over `SHA256(payload)`.
64+
sig: schnorr::Signature,
65+
}
66+
67+
impl_writeable_tlv_based!(SignedFeeClaim, {
68+
(0, payload, required),
69+
(2, sig, required),
70+
});
71+
72+
/// Why a presented claim was not honoured. Every variant resolves the peer to `Flat(Standard)`.
73+
#[derive(Clone, Debug, PartialEq, Eq)]
74+
pub(crate) enum ClaimError {
75+
/// The hex, the outer container, or the inner payload failed to decode.
76+
Malformed,
77+
/// The payload's scheme tag is not one this version understands.
78+
UnknownScheme(u8),
79+
/// No configured issuer key verified the signature (an empty issuer set always lands here).
80+
BadSignature,
81+
/// The claim is valid but bound to a different node than the one presenting it.
82+
NodeIdMismatch,
83+
}
84+
85+
/// Verify a hex-encoded [`SignedFeeClaim`] and return the granted [`FeePolicy`].
86+
///
87+
/// A claim is accepted when it decodes, carries [`CLAIM_SCHEME_V1`], is signed by *any* of
88+
/// `issuer_pubkeys`, and is bound to `counterparty`. An empty `issuer_pubkeys` rejects every claim
89+
/// ([`ClaimError::BadSignature`]), which is how the feature stays inert until a key is configured.
90+
///
91+
/// `scheme` is read and matched *before* the signature because it selects the verification rules:
92+
/// a future scheme could sign a different digest, so there is no single "verify first" step that
93+
/// works across schemes. Only `scheme == 1` (BIP340 over `SHA256(payload)`) exists today.
94+
///
95+
/// Every configured key is trusted equally — any one of them may grant any [`FeePolicy`]. That is
96+
/// fine for a single issuer. Once a second, less-trusted issuer exists (e.g. a promo key that must
97+
/// not be able to grant a permanent zero-fee), the trust set has to carry per-key scope checked
98+
/// against the granted policy. That gap is deliberate, not forgotten; a keyed `&[(key, scope)]`
99+
/// shape plus a payload key-id are additive when it lands.
100+
pub(crate) fn verify_claim<C: Verification>(
101+
secp_ctx: &Secp256k1<C>, fee_claim: &str, issuer_pubkeys: &[XOnlyPublicKey],
102+
counterparty: &PublicKey,
103+
) -> Result<FeePolicy, ClaimError> {
104+
let bytes = <Vec<u8>>::from_hex(fee_claim).map_err(|_| ClaimError::Malformed)?;
105+
let signed = SignedFeeClaim::read(&mut &bytes[..]).map_err(|_| ClaimError::Malformed)?;
106+
let payload =
107+
ClaimPayload::read(&mut &signed.payload[..]).map_err(|_| ClaimError::Malformed)?;
108+
109+
if payload.scheme != CLAIM_SCHEME_V1 {
110+
return Err(ClaimError::UnknownScheme(payload.scheme));
111+
}
112+
113+
let digest = Message::from_digest(sha256::Hash::hash(&signed.payload).to_byte_array());
114+
let verified =
115+
issuer_pubkeys.iter().any(|pk| secp_ctx.verify_schnorr(&signed.sig, &digest, pk).is_ok());
116+
if !verified {
117+
return Err(ClaimError::BadSignature);
118+
}
119+
120+
if payload.node_id != *counterparty {
121+
return Err(ClaimError::NodeIdMismatch);
122+
}
123+
124+
Ok(payload.policy)
125+
}
126+
127+
#[cfg(test)]
128+
mod tests {
129+
use super::*;
130+
use crate::lsps4::fee_policy::FeeTier;
131+
use crate::lsps4::utils;
132+
use bitcoin::secp256k1::{Keypair, SecretKey, VerifyOnly};
133+
134+
/// A throwaway verification context for the test call sites. Production threads the service's
135+
/// long-lived context instead.
136+
fn verify_ctx() -> Secp256k1<VerifyOnly> {
137+
Secp256k1::verification_only()
138+
}
139+
140+
/// Fixed issuer secret used to mint the in-tree test vector. Test-only; never a real key.
141+
const ISSUER_SECRET: [u8; 32] = [0x42; 32];
142+
/// Fixed node secret whose public key the test vector binds the grant to.
143+
const NODE_SECRET: [u8; 32] = [0x11; 32];
144+
145+
fn issuer_keypair() -> Keypair {
146+
let secp = Secp256k1::new();
147+
Keypair::from_secret_key(&secp, &SecretKey::from_slice(&ISSUER_SECRET).unwrap())
148+
}
149+
150+
fn issuer_xonly() -> XOnlyPublicKey {
151+
issuer_keypair().x_only_public_key().0
152+
}
153+
154+
fn node_id() -> PublicKey {
155+
let secp = Secp256k1::new();
156+
PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&NODE_SECRET).unwrap())
157+
}
158+
159+
/// Mint a claim the way the off-band issuer is expected to: sign `SHA256(payload)` with a
160+
/// deterministic (no-aux-rand) BIP340 signature so the resulting hex is a stable vector.
161+
fn mint_claim(node_id: PublicKey, policy: FeePolicy, sk: &[u8; 32]) -> String {
162+
let secp = Secp256k1::new();
163+
let keypair = Keypair::from_secret_key(&secp, &SecretKey::from_slice(sk).unwrap());
164+
let payload = ClaimPayload { scheme: CLAIM_SCHEME_V1, node_id, policy }.encode();
165+
let digest = Message::from_digest(sha256::Hash::hash(&payload).to_byte_array());
166+
let sig = secp.sign_schnorr_no_aux_rand(&digest, &keypair);
167+
utils::to_string(&SignedFeeClaim { payload, sig }.encode())
168+
}
169+
170+
#[test]
171+
fn claim_payload_round_trips() {
172+
let payload = ClaimPayload {
173+
scheme: CLAIM_SCHEME_V1,
174+
node_id: node_id(),
175+
policy: FeePolicy::Flat(FeeTier::ZeroFee),
176+
};
177+
let bytes = payload.encode();
178+
assert_eq!(payload, ClaimPayload::read(&mut &bytes[..]).unwrap());
179+
}
180+
181+
#[test]
182+
fn valid_claim_yields_granted_policy() {
183+
let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET);
184+
assert_eq!(
185+
verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()),
186+
Ok(FeePolicy::Flat(FeeTier::ZeroFee))
187+
);
188+
}
189+
190+
#[test]
191+
fn empty_issuer_set_rejects() {
192+
let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET);
193+
assert_eq!(
194+
verify_claim(&verify_ctx(), &claim, &[], &node_id()),
195+
Err(ClaimError::BadSignature)
196+
);
197+
}
198+
199+
#[test]
200+
fn wrong_issuer_key_rejects() {
201+
let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET);
202+
// An issuer key that did not sign this claim.
203+
let secp = Secp256k1::new();
204+
let other = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[0x07; 32]).unwrap())
205+
.x_only_public_key()
206+
.0;
207+
assert_eq!(
208+
verify_claim(&verify_ctx(), &claim, &[other], &node_id()),
209+
Err(ClaimError::BadSignature)
210+
);
211+
}
212+
213+
#[test]
214+
fn forged_signature_rejects() {
215+
// Mint with the wrong secret, then check against the real issuer's key.
216+
let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &[0x07; 32]);
217+
assert_eq!(
218+
verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()),
219+
Err(ClaimError::BadSignature)
220+
);
221+
}
222+
223+
#[test]
224+
fn node_id_mismatch_rejects() {
225+
let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET);
226+
// A different counterparty than the one the claim is bound to.
227+
let secp = Secp256k1::new();
228+
let other =
229+
PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[0x22; 32]).unwrap());
230+
assert_eq!(
231+
verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &other),
232+
Err(ClaimError::NodeIdMismatch)
233+
);
234+
}
235+
236+
#[test]
237+
fn unknown_scheme_rejects() {
238+
let secp = Secp256k1::new();
239+
let keypair = issuer_keypair();
240+
let payload = ClaimPayload { scheme: 2, node_id: node_id(), policy: FeePolicy::Flat(FeeTier::ZeroFee) }
241+
.encode();
242+
let digest = Message::from_digest(sha256::Hash::hash(&payload).to_byte_array());
243+
let sig = secp.sign_schnorr_no_aux_rand(&digest, &keypair);
244+
let claim = utils::to_string(&SignedFeeClaim { payload, sig }.encode());
245+
assert_eq!(
246+
verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()),
247+
Err(ClaimError::UnknownScheme(2))
248+
);
249+
}
250+
251+
#[test]
252+
fn malformed_hex_rejects() {
253+
assert_eq!(
254+
verify_claim(&verify_ctx(), "zzzz", &[issuer_xonly()], &node_id()),
255+
Err(ClaimError::Malformed)
256+
);
257+
}
258+
259+
#[test]
260+
fn malformed_tlv_rejects() {
261+
// Valid hex, but not a decodable SignedFeeClaim.
262+
let claim = utils::to_string(&[0xff, 0xff, 0xff]);
263+
assert_eq!(
264+
verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()),
265+
Err(ClaimError::Malformed)
266+
);
267+
}
268+
269+
#[test]
270+
fn multi_issuer_second_key_verifies() {
271+
// The signing key sits second in the trust set, so this exercises `.any()` past index 0 —
272+
// the multi-issuer / key-rotation path the config shape is built for.
273+
let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET);
274+
let secp = Secp256k1::new();
275+
let other = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[0x07; 32]).unwrap())
276+
.x_only_public_key()
277+
.0;
278+
assert_eq!(
279+
verify_claim(&verify_ctx(), &claim, &[other, issuer_xonly()], &node_id()),
280+
Ok(FeePolicy::Flat(FeeTier::ZeroFee))
281+
);
282+
}
283+
284+
#[test]
285+
fn custom_policy_survives_verify() {
286+
// Any signed FeePolicy comes back intact, not just ZeroFee.
287+
let policy = FeePolicy::Flat(FeeTier::Custom { ppm: 1_234, base_msat: 56 });
288+
let claim = mint_claim(node_id(), policy.clone(), &ISSUER_SECRET);
289+
assert_eq!(
290+
verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()),
291+
Ok(policy)
292+
);
293+
}
294+
295+
/// The cross-repo contract the client and the issuer must reproduce byte-for-byte. The issuer
296+
/// secret, the bound node id, and a `ZeroFee` grant mint exactly this hex; pinning it here
297+
/// catches any drift in the TLV byte layout or the signing input.
298+
#[test]
299+
fn in_tree_test_vector() {
300+
const EXPECTED_CLAIM_HEX: &str = "73002f002d2c0001010221034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa04040002020002408f3868ca716c39d580d8b54c8d852c22f0f1ea4a174ba13ad571e37fd182aa60e82a88c00225a3f61112804cf1e7c41bd39dbdc7fcb78e779b78423fff47d964";
301+
const EXPECTED_ISSUER_XONLY: &str =
302+
"24653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c";
303+
304+
assert_eq!(utils::to_string(&issuer_xonly().serialize()), EXPECTED_ISSUER_XONLY);
305+
306+
let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET);
307+
assert_eq!(claim, EXPECTED_CLAIM_HEX);
308+
assert_eq!(
309+
verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()),
310+
Ok(FeePolicy::Flat(FeeTier::ZeroFee))
311+
);
312+
}
313+
}

lightning-liquidity/src/lsps4/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
//! Implementation of LSPS4: JIT Channel Negotiation specification.
1111
12+
pub(crate) mod claim;
1213
pub mod client;
1314
pub mod event;
1415
pub mod fee_policy;

0 commit comments

Comments
 (0)