smite: validate negotiated features and channel types in accept_channel oracle - #209
NishantBansal2003 wants to merge 4 commits into
Conversation
Oh, I like how you added |
Yeah, I just added that function to keep things simple, but this could be done via an |
33dd398 to
05bce1c
Compare
| /// Returns whether every bit set here is supported by `other`, where the | ||
| /// feature's required (even) or optional (odd) bit both count as support. | ||
| #[must_use] | ||
| pub fn is_supported(&self, other: &Features) -> bool { |
There was a problem hiding this comment.
nit: Maybe is_compatible would be a better name?
There was a problem hiding this comment.
Renamed to is_supported_by, I think that would be clearer when checking whether something is a subset of a feature
| } | ||
|
|
||
| /// Returns the maximum funding amount allowed by the negotiated features. | ||
| pub fn max_funding_satoshis(negotiated_features: &Features) -> u64 { |
There was a problem hiding this comment.
| pub fn max_funding_satoshis(negotiated_features: &Features) -> u64 { | |
| fn max_funding_satoshis(negotiated_features: &Features) -> u64 { |
| } | ||
|
|
||
| /// Returns the maximum number of inbound HTLCs allowed by the channel type. | ||
| pub fn max_accepted_htlcs_limit(channel_type: &Features) -> u16 { |
There was a problem hiding this comment.
| pub fn max_accepted_htlcs_limit(channel_type: &Features) -> u16 { | |
| fn max_accepted_htlcs_limit(channel_type: &Features) -> u16 { |
| // Since we echo the same features the target sent, but strip both | ||
| // required and optional bits to exercise only the single funded | ||
| // flow and avoid unrelated noise, negotiated features are just the | ||
| // features we sent in our init. | ||
| negotiated_features: Features::from(our_init.features), |
There was a problem hiding this comment.
Shouldn't this be a bitwise-or of our_init.globalfeatures and our_init.features?
- MUST combine (bitwise OR) the two feature bitmaps into one logical
featuresmap.
There was a problem hiding this comment.
Spec-wise, yes, but I think globalfeatures is a legacy feature. From what I can see, the spec doesn't require the two vectors to be disjoint, and in practice, every target duplicates rather than partitions them: LDK sends a masked copy of bits 0-13 from its own features, CLN and LND send only var_onion/static_remotekey, which they also set in features, and Eclair sends it empty. globalfeatures is a subset of features for all four, so there shouldn't be any issues if we just use features instead of OR-ing them
see:
Signed-off-by: Nishant Bansal <[email protected]>
We strip certain feature bits during setup to exercise only the single funded flow, so the features stored here are what both sides have agreed to continue with, the negotiated feature set, not just the target's advertised features. This prepares for oracle validation that will use negotiated features to validate field constraints. If the target didn't disconnect after our init, that confirms it also conforms to our negotiated features, not its original advertised feature set. Signed-off-by: Nishant Bansal <[email protected]>
This is useful when comparing features in message fields against negotiated features. For eg., comparing channel_type in open_channel and accept_channel to ensure they match the features negotiated during setup. Signed-off-by: Nishant Bansal <[email protected]>
Signed-off-by: Nishant Bansal <[email protected]>
05bce1c to
adece46
Compare
| /// Features negotiated between the target node and Smite. | ||
| pub negotiated_features: Features, |
There was a problem hiding this comment.
What confused me a little here is that since these are already negotiated features, I'm not sure what would be the difference between an odd vs even bit for a feature.
is_supported_by checks both bits, so for the rest of the code, it doesn't matter which bit of a feature you set afaict. If I could tell correctly, I think this would be nice to clarify:
| /// Features negotiated between the target node and Smite. | |
| pub negotiated_features: Features, | |
| /// Features negotiated between the target node and Smite. A feature may | |
| /// appear as an even or odd bit; the distinction carries no meaning here. | |
| pub negotiated_features: Features, |
| // be supported. | ||
| if !negotiated_features.supports_feature(Features::OPTION_CHANNEL_TYPE) { | ||
| return Err("option_channel_type is not supported".to_string()); | ||
| } |
There was a problem hiding this comment.
I thought ASSUMED and "can safely be ignored" in BOLT-9 means that the peer doesn't need to set any bit for this feature, but we simply assume it's enabled. So I'm not sure if this check here makes sense.
There was a problem hiding this comment.
Agree. I don't think it's a spec violation to omit the feature bit -- the idea behind making it ASSUMED was to allow reusing the feature bit eventually with a different meaning.
| negotiated_features: Features::from_bits(&[ | ||
| Features::OPTION_STATIC_REMOTEKEY, | ||
| Features::OPTION_ANCHORS, | ||
| Features::OPTION_CHANNEL_TYPE, | ||
| ]), |
There was a problem hiding this comment.
Related to my other comment about removing the check for OPTION_CHANNEL_TYPE: I interpret "can be safely ignored" as "don't check if the feature is enabled; assume it is", so a peer who didn't set this bit in init isn't wrong.
I think it could make sense to distinguish which bits we set here because we assume them to be negotiated (OPTION_STATIC_REMOTEKEY and OPTION_CHANNEL_TYPE) vs. for other reasons (OPTION_ANCHORS). So instead of adding assumed feature bits manually here, we could have a list of assumed features, and then include them here for our sample ProgramContext in the tests and in SnapshotSetup during fuzzing.
However, this is related to the question of whether we even need to include assumed bits in our context in the first place if we can assume them to be set, so all checks based on these features are unconditional.
We could also postpone this discussion for an InitOracle, because it would be more concerned with the meaning of bits, like that only one bit should be set for a feature.
WDYT?
There was a problem hiding this comment.
Will remove the check for OPTION_CHANNEL_TYPE being present in the negotiated features, so I think we can safely remove OPTION_CHANNEL_TYPE from the negotiated features as well
| } | ||
|
|
||
| // Check the channel reserve covers the dust limit. | ||
| if accept_channel.dust_limit_satoshis > accept_channel.channel_reserve_satoshis { |
There was a problem hiding this comment.
Unrelated to this PR: Shouldn't this be this?
-if accept_channel.dust_limit_satoshis > accept_channel.channel_reserve_satoshis
+if accept_channel.dust_limit_satoshis > open_channel.channel_reserve_satoshis
ekzyis
left a comment
There was a problem hiding this comment.
Left some more nits, but LGTM! Will probably ACK tomorrow
| } | ||
|
|
||
| // Check that option_scid_alias is only negotiated for private channels. | ||
| let announce_channel = (open_channel.channel_flags & 1) == 1; |
There was a problem hiding this comment.
nit: could define a constant ANNOUNCE_CHANNEL_FLAG so it's clear why we use 1 here without looking up the spec. It's kind of easy to miss in the spec:
| ) -> Result<(), String> { | ||
| // Check that option_dual_fund has not been negotiated. | ||
| if negotiated_features.supports_feature(Features::OPTION_DUAL_FUND) { | ||
| return Err("option_dual_fund has been negotiated".to_string()); |
There was a problem hiding this comment.
nit: We use supports_feature here and at line 138, but the error message uses "negotiated" vs "supported". I think we should use one verb consistently in the error messages. I have a slight preference towards "negotiated".
| // Check option_channel_type in negotiated features since it is assumed to | ||
| // be supported. | ||
| if !negotiated_features.supports_feature(Features::OPTION_CHANNEL_TYPE) { | ||
| return Err("option_channel_type is not supported".to_string()); |
There was a problem hiding this comment.
nit: see other comment
| return Err("option_channel_type is not supported".to_string()); | |
| return Err("option_channel_type has not been negotiated".to_string()); |
but would also need replacement in tests
| // The pairing is symmetric: an optional bit on the left is satisfied | ||
| // by the required bit on the right. |
There was a problem hiding this comment.
nit:
| // The pairing is symmetric: an optional bit on the left is satisfied | |
| // by the required bit on the right. | |
| // The pairing is symmetric: an optional bit on the left is satisfied | |
| // by the required bit on the right and vice versa. |
| return Err("upfront_shutdown_script is not valid".to_string()); | ||
| } | ||
| } else { | ||
| return Err("open_channel does not include upfront_shutdown_script".to_string()); |
There was a problem hiding this comment.
nit: I like how we use only upfront_shutdown_script here, but we use a channel_type in other places. Probably not worth fixing, but I thought it was worth mentioning.
| if let Some(script) = &open_channel.tlvs.upfront_shutdown_script { | ||
| if !script.is_empty() && !is_acceptable_shutdown_script(script, negotiated_features) { | ||
| return Err("upfront_shutdown_script is not valid".to_string()); | ||
| } | ||
| } else { | ||
| return Err("open_channel does not include upfront_shutdown_script".to_string()); | ||
| } |
There was a problem hiding this comment.
refactor suggestion to avoid triple nesting:
diff --git a/smite/src/oracles/accept_channel.rs b/smite/src/oracles/accept_channel.rs
index c07c378..a80d53b 100644
--- a/smite/src/oracles/accept_channel.rs
+++ b/smite/src/oracles/accept_channel.rs
@@ -123,12 +123,11 @@ fn verify_accepted_open_channel(
// Check that the upfront shutdown script is present and valid when negotiated.
if negotiated_features.supports_feature(Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT) {
- if let Some(script) = &open_channel.tlvs.upfront_shutdown_script {
- if !script.is_empty() && !is_acceptable_shutdown_script(script, negotiated_features) {
- return Err("upfront_shutdown_script is not valid".to_string());
- }
- } else {
+ let Some(script) = &open_channel.tlvs.upfront_shutdown_script else {
return Err("open_channel does not include upfront_shutdown_script".to_string());
+ };
+ if !script.is_empty() && !is_acceptable_shutdown_script(script, negotiated_features) {
+ return Err("upfront_shutdown_script is not valid".to_string());
}
}
@@ -218,12 +217,11 @@ fn verify_accept_channel(
) -> Result<(), String> {
// Check that the upfront shutdown script is present and valid when negotiated.
if negotiated_features.supports_feature(Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT) {
- if let Some(script) = &accept_channel.tlvs.upfront_shutdown_script {
- if !script.is_empty() && !is_standard_shutdown_script(script, negotiated_features) {
- return Err("upfront_shutdown_script is not valid".to_string());
- }
- } else {
+ let Some(script) = &accept_channel.tlvs.upfront_shutdown_script else {
return Err("accept_channel does not include upfront_shutdown_script".to_string());
+ };
+ if !script.is_empty() && !is_standard_shutdown_script(script, negotiated_features) {
+ return Err("upfront_shutdown_script is not valid".to_string());
}
}| /// - dust limit greater than channel reserve: BOLT 2 requires the dust limit to | ||
| /// be less than or equal to the channel reserve. However, implementations | ||
| /// such as LDK accept zero channel reserves on the receiving side, so we do | ||
| /// not enforce this check on the target's receiving side. |
There was a problem hiding this comment.
The following checks appear to be unintentionally missing:
- unexpected
chain_hash - unreasonably large
to_self_delay feerate_per_kwthat is too small or unreasonably large
I think we could add 1. by adding expected_chain_hash to AcceptChannelContext. For 2. and 3., we would need to decide which values are too small or unreasonably large. I think we've done this before for other values, i.e. bitcoin amounts > 21m don't make sense.
There was a problem hiding this comment.
- Planned as a follow-up, see the PR description
- Default Max for all is 2016, so maybe we should also check
to_self_delay <= 2016inopen_channelonly - The cap on small is already planned, see the PR description. For the large feerate, I don't think it affects any target, since it would only be set by the opener to pay
| const MAX_FUNDING_SATOSHIS_NO_WUMBO: u64 = (1 << 24) - 1; | ||
| const MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS: u16 = 114; | ||
| const MAX_ACCEPTED_HTLCS_DEFAULT: u16 = 483; |
There was a problem hiding this comment.
nit: could rename MAX_FUNDING_SATOSHIS_NO_WUMBO to MAX_FUNDING_SATOSHIS_DEFAULT to be consistent with MAX_ACCEPTED_HTLCS_DEFAULT
There was a problem hiding this comment.
Would prefer MAX_FUNDING_SATOSHIS_NO_WUMBO only, since MAX_FUNDING_SATOSHIS_DEFAULT could be confused with the Max Bitcoin supply
| if announce_channel && channel_type.supports_feature(Features::OPTION_SCID_ALIAS) { | ||
| return Err("option_scid_alias requires the channel to be private".to_string()); | ||
| } |
There was a problem hiding this comment.
I see the spec sender requirement for this but not the receiver one. Should we update the spec with it?
There was a problem hiding this comment.
This could be clarified in the spec, and it would be great if the receiver rejected these. Since HTLCs are rejected immediately rather than held until expiry, the reputation loss would not be significant, though it might eventually affect the target. I think this would be beneficial for the network and nodes, but should not be required
I will update the spec and see what others think about it.
| } | ||
|
|
||
| // Check the channel reserve covers the dust limit. | ||
| if accept_channel.dust_limit_satoshis > accept_channel.channel_reserve_satoshis { |
| #[test] | ||
| fn commitment_validation_skipped_for_zero_fee_commitments() { | ||
| let mut oc = open_channel(); | ||
| oc.tlvs.channel_type = Some(ChannelTypeVariant::ZeroFeeCommitments.encode()); | ||
| oc.feerate_per_kw = 0; | ||
| oc.push_msat = oc.funding_satoshis * 1000; | ||
| oc.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS; | ||
| let mut ac = accept_channel(); | ||
| ac.tlvs.channel_type = Some(ChannelTypeVariant::ZeroFeeCommitments.encode()); | ||
| ac.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS; | ||
|
|
||
| assert_pass(&ac, Some(&pending_negotiation(oc))); | ||
| } | ||
|
|
||
| #[test] | ||
| fn commitment_validation_skipped_for_option_simple_taproot() { | ||
| let mut oc = open_channel(); | ||
| oc.tlvs.channel_type = Some(ChannelTypeVariant::SimpleTaproot.encode()); | ||
| oc.push_msat = oc.funding_satoshis * 1000; | ||
| let mut ac = accept_channel(); | ||
| ac.tlvs.channel_type = Some(ChannelTypeVariant::SimpleTaproot.encode()); | ||
|
|
||
| assert_pass(&ac, Some(&pending_negotiation(oc))); | ||
| } |
There was a problem hiding this comment.
IIUC, the first test is for an acceptable behavior (i.e. the opener should be able to push the full channel balance out for 0FC since they don't have to pay for the anchor or commitment fee).
But I don't think that's true for taproot channels -- the opener still needs to afford the anchor output and commitment fees. So the second test may actually be something we should eventually reject...
There was a problem hiding this comment.
- The first test is currently passing by chance and will also pass once we add support for 0FC, so it is fine to keep it as is
- The second test fails without a barrier and will also fail once we update the commitment fee for taproot channels, It should be updated once support for it is added
Will add comments to both of these tests to make it clear
| /// Returns whether every bit set here is supported by `other`, where the | ||
| /// feature's required (even) or optional (odd) bit both count as support. | ||
| #[must_use] | ||
| pub fn is_supported_by(&self, other: &Features) -> bool { |
There was a problem hiding this comment.
Nit: I think it's more consistent with the existing methods to call this supports_features and swap the usage of self and other
| fn open_channel_max_accepted_htlcs_above_the_limit() { | ||
| fn open_channel_channel_type_contains_non_negotiated_features() { | ||
| let mut oc = open_channel(); | ||
| oc.tlvs.channel_type = Some(vec![0x10, 0x00]); |
There was a problem hiding this comment.
Can we use the existing constants here for clarity?
| oc.tlvs.channel_type = Some(vec![0x40, 0x40, 0x10, 0x00]); | ||
|
|
||
| let mut negotiated_features = Features::from(vec![0x40, 0x40, 0x10, 0x00]); |
There was a problem hiding this comment.
Can we use constants here for clarity?
| // be supported. | ||
| if !negotiated_features.supports_feature(Features::OPTION_CHANNEL_TYPE) { | ||
| return Err("option_channel_type is not supported".to_string()); | ||
| } |
There was a problem hiding this comment.
Agree. I don't think it's a spec violation to omit the feature bit -- the idea behind making it ASSUMED was to allow reusing the feature bit eventually with a different meaning.
| // Check that the upfront shutdown script is present and valid when negotiated. | ||
| if negotiated_features.supports_feature(Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT) { |
There was a problem hiding this comment.
I couldn't find anywhere in the spec that requires the receiver to validate the sender's shutdown script, and I'm not sure it's needed. IIUC the worst thing that happens in this case is that the sender uses an invalid/unspendable script, which harms themselves, not the receiver.
There was a problem hiding this comment.
I think if the sender script is invalid, a cooperative close is impossible for the channel's lifetime, and if the target accepted it without validating it, the only option to close the channel is a force-close, leaving its to_local output delayed
Depends-on: #192
Depends-on: #186 (added some changes to it, which will be removed once that PR is finalized)
Opening this as a draft since it depends on two PRs. Otherwise, the changes are ready for review.
I tried to add all the definitive checks for
open_channelandaccept_channelmentioned in the BOLT 2, including ones that aren't currently possible, such asoption_dual_fund, since we haven't negotiated it. I think having parity with the BOLTs is good, especially for the future when we extend smite to support more protocolsSome more missing oracles that I will add in follow-up PR are:
open_channelchain_hash != regtestchannel_reserve_satoshis >= funding_satoshisdust_limit_satoshis > 10_000satto_self_delay > 2016feerate_per_kw < 253for non 0FC channelsaccept_channeldust_limit_satoshis > 10_000sathtlc_minimum_msat > max_htlc_value_in_flight_msathtlc_minimum_msat > open_channel.funding_satoshisto_self_delay < 144max_accepted_htlcs == 0open_channelandaccept_channelper_commitment_pointshould not be reusedKnown violations found by this that need to be suppressed are:
option_scid_aliason an announced channel ElementsProject/lightning#9444