Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
fetch: Configure Minimum Feature Level
Lorenz Leutgeb committed 1 month ago
commit e245e3115bcb644ebf8ae99338c6061b397dddd5
parent 4706305
10 files changed +217 -29
modified CHANGELOG.md
@@ -30,6 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
  the node's inventory, where the node has a reference
  `refs/namespaces/<NID>/refs/rad/sigrefs`. The migration will only take place
  if the `rad/sigrefs` found were below the latest feature level, i.e. `parent`.
+
- The fetch process, between nodes, can reject `refs/namespaces` that have a
+
  signed references feature level that is below an expected minimum. This
+
  minimum can be configured in the Radicle configuration file under
+
  `node.fetch.signedReferences.featureLevel.minimum`.

## 1.7.1

modified crates/radicle-cli/examples/rad-config.md
@@ -315,6 +315,10 @@ $ rad config schema
          "description": "Database configuration.",
          "$ref": "#/$defs/Config"
        },
+
        "fetch": {
+
          "description": "Configuration for fetching from other nodes.",
+
          "$ref": "#/$defs/Fetch"
+
        },
        "secret": {
          "description": "Path to a file containing an Ed25519 secret key, in OpenSSH format, i.e./nwith the `-----BEGIN OPENSSH PRIVATE KEY-----` header. The corresponding/npublic key will be used as the Node ID./n/nA decryption password cannot be configured, but passed at runtime via/nthe environment variable `RAD_PASSPHRASE`.",
          "type": [
@@ -720,6 +724,52 @@ $ rad config schema
        "NORMAL",
        "OFF"
      ]
+
    },
+
    "Fetch": {
+
      "description": "Configuration for fetching repositories from/nother nodes.",
+
      "type": "object",
+
      "properties": {
+
        "signedReferences": {
+
          "$ref": "#/$defs/SignedReferencesConfig"
+
        }
+
      }
+
    },
+
    "SignedReferencesConfig": {
+
      "type": "object",
+
      "properties": {
+
        "featureLevel": {
+
          "$ref": "#/$defs/FeatureLevelConfig"
+
        }
+
      }
+
    },
+
    "FeatureLevelConfig": {
+
      "type": "object",
+
      "properties": {
+
        "minimum": {
+
          "description": "The minimum feature level required to accept incoming/nreferences from other users. This value is compared/nagainst the feature level detected on refs as they are/nfetched./n/nNote that by increasing this value, security can be/ntraded for compatibility. The higher the value,/nthe less backward compatible, but the more secure, fetches will be.",
+
          "$ref": "#/$defs/FeatureLevel"
+
        }
+
      }
+
    },
+
    "FeatureLevel": {
+
      "description": "The Signed References feature has evolved over time./nThis enum captures the corresponding /"feature level/"./n/nFeature levels are monotonic, in the sense that a greater feature level/nencompasses all the features of smaller ones.",
+
      "oneOf": [
+
        {
+
          "description": "The lowest feature level, with least security. It is vulnerable to/ngraft attacks and replay attacks.",
+
          "type": "string",
+
          "const": "none"
+
        },
+
        {
+
          "description": "An intermediate feature level, which protects against graft attacks but is vulnerable to replay attacks. Introduced in Radicle 1.1.0, in commit `989edacd564fa658358f5ccfd08c243c5ebd8cda`.",
+
          "type": "string",
+
          "const": "root"
+
        },
+
        {
+
          "description": "The highest feature level known, which protects against graft attacks and replay attacks. Introduced in Radicle 1.7.0, in commit `d3bc868e84c334f113806df1737f52cc57c5453d`.",
+
          "type": "string",
+
          "const": "parent"
+
        }
+
      ]
    }
  }
}
modified crates/radicle-fetch/src/lib.rs
@@ -18,7 +18,7 @@ pub use gix_protocol::{transport::bstr::ByteSlice, RemoteProgress};
pub use handle::Handle;
pub use policy::{Allowed, BlockList, Scope};
use radicle::storage::git::Repository;
-
pub use state::{FetchLimit, FetchResult};
+
pub use state::{Config, FetchLimit, FetchResult};
pub use transport::Transport;

use radicle::crypto::PublicKey;
@@ -65,7 +65,7 @@ pub enum HandshakeError {
/// [`clone`] should be used.
pub fn pull<R, S>(
    handle: &mut Handle<R, S>,
-
    limit: FetchLimit,
+
    config: Config,
    remote: PublicKey,
    refs_at: Option<Vec<RefsAt>>,
) -> Result<FetchResult, Error>
@@ -84,7 +84,7 @@ where
    // N.b. ensure that we ignore the local peer's key.
    handle.blocked.extend([local]);
    let result = state
-
        .run(handle, &handshake, limit, remote, refs_at)
+
        .run(handle, &handshake, config, remote, refs_at)
        .map_err(Error::Protocol);

    log::debug!(
@@ -101,7 +101,7 @@ where
/// they want to populate with the `remote`'s view of the project.
pub fn clone<R, S>(
    handle: &mut Handle<R, S>,
-
    limit: FetchLimit,
+
    config: Config,
    remote: PublicKey,
) -> Result<FetchResult, Error>
where
@@ -115,7 +115,7 @@ where
    let handshake = perform_handshake(handle)?;
    let state = FetchState::default();
    let result = state
-
        .run(handle, &handshake, limit, remote, None)
+
        .run(handle, &handshake, config, remote, None)
        .map_err(Error::Protocol);
    let elapsed = start.elapsed().as_millis();
    let rid = handle.repository().id();
modified crates/radicle-fetch/src/state.rs
@@ -100,6 +100,12 @@ pub struct FetchLimit {
    pub refs: u64,
}

+
#[derive(Clone, Copy, Debug, Default)]
+
pub struct Config {
+
    pub limit: FetchLimit,
+
    pub level_min: FeatureLevel,
+
}
+

impl Default for FetchLimit {
    fn default() -> Self {
        Self {
@@ -358,7 +364,7 @@ impl FetchState {
        mut self,
        handle: &mut Handle<R, S>,
        handshake: &Handshake,
-
        limit: FetchLimit,
+
        config: Config,
        remote: PublicKey,
        refs_at: Option<Vec<RefsAt>>,
    ) -> Result<FetchResult, error::Protocol>
@@ -375,7 +381,7 @@ impl FetchState {
            handshake,
            &stage::CanonicalId {
                remote,
-
                limit: limit.special,
+
                limit: config.limit.special,
            },
        )?;
        log::debug!("Fetched rad/id ({}ms)", start.elapsed().as_millis());
@@ -413,7 +419,7 @@ impl FetchState {
            handshake,
            delegates.clone(),
            threshold,
-
            &limit,
+
            &config.limit,
            remote,
            refs_at,
        )?;
@@ -500,6 +506,26 @@ impl FetchState {
                        source: err,
                    });
                }
+
                (Ok(Some(refs)), delegate) if refs.feature_level() < config.level_min => {
+
                    log::debug!(
+
                        "Pruning {remote} tips due to insufficient feature level '{}' < '{}'",
+
                        refs.feature_level(),
+
                        config.level_min
+
                    );
+

+
                    failures.push(sigrefs::Validation::InsufficientFeatureLevel {
+
                        remote,
+
                        actual: refs.feature_level(),
+
                        minimum: config.level_min,
+
                    });
+

+
                    if delegate {
+
                        valid_delegates.remove(&remote);
+
                        failed_delegates.insert(remote);
+
                    }
+

+
                    self.prune(&remote);
+
                }
                (Ok(Some(refs)), false) => {
                    if let Some(SignedRefsAt { at, .. }) =
                        SignedRefsAt::load(remote, handle.repository())?
modified crates/radicle-node/src/runtime.rs
@@ -15,7 +15,6 @@ use cyphernet::Ecdh;
use radicle::cob::migrate;
use radicle::crypto;
use radicle::node::device::Device;
-
use radicle_fetch::FetchLimit;
use radicle_signals::Signal;
use thiserror::Error;

@@ -236,7 +235,10 @@ impl Runtime {

        let nid = *signer.public_key();
        let fetch = worker::FetchConfig {
-
            limit: FetchLimit::default(),
+
            config: radicle_fetch::Config {
+
                limit: radicle_fetch::FetchLimit::default(),
+
                level_min: config.fetch.feature_level_min(),
+
            },
            local: nid,
            expiry: worker::garbage::Expiry::default(),
        };
modified crates/radicle-node/src/worker.rs
@@ -17,7 +17,6 @@ use radicle::prelude::NodeId;
use radicle::storage::refs::RefsAt;
use radicle::storage::{ReadRepository, ReadStorage};
use radicle::{cob, crypto, Storage};
-
use radicle_fetch::FetchLimit;

pub use radicle_protocol::worker::{
    AuthorizationError, FetchError, FetchRequest, FetchResult, UploadError,
@@ -60,8 +59,8 @@ pub struct TaskResult {

#[derive(Debug, Clone)]
pub struct FetchConfig {
-
    /// Data limits when fetching from a remote.
-
    pub limit: FetchLimit,
+
    /// Configuration passed to the fetch protocol.
+
    pub config: radicle_fetch::Config,
    /// Public key of the local peer.
    pub local: crypto::PublicKey,
    /// Configuration for `git gc` garbage collection. Defaults to `1
@@ -243,7 +242,7 @@ impl Worker {
        notifs: notifications::StoreWriter,
    ) -> Result<fetch::FetchResult, FetchError> {
        let FetchConfig {
-
            limit,
+
            config,
            local,
            expiry,
        } = &self.fetch_config;
@@ -267,7 +266,7 @@ impl Worker {
            &self.storage,
            &mut cache,
            &mut self.db,
-
            *limit,
+
            *config,
            remote,
            refs_at,
        )?;
modified crates/radicle-node/src/worker/fetch.rs
@@ -21,7 +21,7 @@ use radicle::storage::{
};
use radicle::{cob, git, node, Storage};
use radicle_fetch::git::refs::Applied;
-
use radicle_fetch::{Allowed, BlockList, FetchLimit};
+
use radicle_fetch::{Allowed, BlockList};
pub use radicle_protocol::worker::fetch::{FetchResult, UpdatedCanonicalRefs};

use super::channels::ChannelsFlush;
@@ -67,20 +67,24 @@ impl Handle {
        storage: &Storage,
        cache: &mut cob::cache::StoreWriter,
        refsdb: &mut D,
-
        limit: FetchLimit,
+
        config: radicle_fetch::Config,
        remote: PublicKey,
        refs_at: Option<Vec<RefsAt>>,
    ) -> Result<FetchResult, error::Fetch> {
        let (result, clone, notifs) = match self {
            Self::Clone { mut handle } => {
                log::debug!(target: "worker", "{} cloning from {remote}", handle.local());
-
                match radicle_fetch::clone(&mut handle, limit, remote) {
+
                match radicle_fetch::clone(&mut handle, config, remote) {
                    Err(err) => {
                        handle.into_inner().cleanup();
                        return Err(err.into());
                    }
                    Ok(result) => {
-
                        handle.into_inner().mv(storage.path_of(&rid))?;
+
                        if result.is_success() {
+
                            handle.into_inner().mv(storage.path_of(&rid))?;
+
                        } else {
+
                            handle.into_inner().cleanup();
+
                        }
                        (result, true, None)
                    }
                }
@@ -90,7 +94,7 @@ impl Handle {
                notifications,
            } => {
                log::debug!(target: "worker", "{} pulling from {remote}", handle.local());
-
                let result = radicle_fetch::pull(&mut handle, limit, remote, refs_at)?;
+
                let result = radicle_fetch::pull(&mut handle, config, remote, refs_at)?;
                (result, false, Some(notifications))
            }
        };
modified crates/radicle/src/node/config.rs
@@ -11,6 +11,7 @@ use serde_json as json;
use crate::node;
use crate::node::policy::SeedingPolicy;
use crate::node::{Address, Alias, NodeId};
+
use crate::storage::refs::FeatureLevel;

use super::policy;

@@ -460,6 +461,56 @@ impl From<DefaultSeedingPolicy> for SeedingPolicy {
    }
}

+
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+
pub struct FeatureLevelConfig {
+
    /// The minimum feature level required to accept incoming
+
    /// references from other users. This value is compared
+
    /// against the feature level detected on refs as they are
+
    /// fetched.
+
    ///
+
    /// Note that by increasing this value, security can be
+
    /// traded for compatibility. The higher the value,
+
    /// the less backward compatible, but the more secure, fetches will be.
+
    #[serde(
+
        default,
+
        rename = "minimum",
+
        skip_serializing_if = "crate::serde_ext::is_default"
+
    )]
+
    min: FeatureLevel,
+
}
+

+
impl FeatureLevelConfig {
+
    pub fn min(&self) -> FeatureLevel {
+
        self.min
+
    }
+
}
+

+
/// Configuration for fetching repositories from
+
/// other nodes.
+
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+
pub struct Fetch {
+
    #[serde(default, skip_serializing_if = "crate::serde_ext::is_default")]
+
    signed_references: SignedReferencesConfig,
+
}
+

+
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+
pub struct SignedReferencesConfig {
+
    #[serde(default, skip_serializing_if = "crate::serde_ext::is_default")]
+
    feature_level: FeatureLevelConfig,
+
}
+

+
impl Fetch {
+
    pub fn feature_level_min(&self) -> FeatureLevel {
+
        self.signed_references.feature_level.min()
+
    }
+
}
+

/// Service configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -512,6 +563,9 @@ pub struct Config {
    /// Database configuration.
    #[serde(default, skip_serializing_if = "crate::serde_ext::is_default")]
    pub database: node::db::config::Config,
+
    /// Configuration for fetching from other nodes.
+
    #[serde(default, skip_serializing_if = "crate::serde_ext::is_default")]
+
    pub fetch: Fetch,
    /// Extra fields that aren't supported.
    #[serde(flatten, skip_serializing)]
    pub extra: json::Map<String, json::Value>,
@@ -550,6 +604,7 @@ impl Config {
            seeding_policy: DefaultSeedingPolicy::default(),
            database: node::db::config::Config::default(),
            extra: json::Map::default(),
+
            fetch: Fetch::default(),
            secret: None,
        }
    }
@@ -861,4 +916,25 @@ mod test {
        assert_eq!(got.alias, expected.alias);
        assert_eq!(got.external_addresses, expected.external_addresses);
    }
+

+
    #[test]
+
    fn fetch_level_min() {
+
        let config = json!({
+
            "alias": "radicle",
+
            "fetch": {
+
                "signedReferences": {
+
                    "featureLevel": {
+
                        "minimum": "parent"
+
                    }
+
                }
+
            },
+
        });
+
        let got: super::Config = serde_json::from_value(config).unwrap();
+
        let expected = super::Config::new(Alias::new("radicle"));
+
        assert_eq!(got.alias, expected.alias);
+
        assert_eq!(
+
            got.fetch.feature_level_min(),
+
            crate::storage::refs::FeatureLevel::Parent
+
        );
+
    }
}
modified crates/radicle/src/storage/git.rs
@@ -20,7 +20,7 @@ use crate::identity::{Identity, Project};
use crate::node::device::Device;
use crate::node::SyncedAt;
use crate::storage::refs;
-
use crate::storage::refs::{Refs, SignedRefs, SignedRefsAt};
+
use crate::storage::refs::{FeatureLevel, Refs, SignedRefs, SignedRefsAt};
use crate::storage::{
    ReadRepository, ReadStorage, Remote, Remotes, RepositoryInfo, SetHead, SignRepository,
    WriteRepository, WriteStorage,
@@ -400,6 +400,14 @@ pub enum Validation {
        #[source]
        source: crate::storage::refs::sigrefs::read::error::Read,
    },
+
    #[error(
+
        "rejecting `refs/namespaces/{remote}/refs/rad/sigrefs` on feature level '{actual}', below required minimum '{minimum}'"
+
    )]
+
    InsufficientFeatureLevel {
+
        remote: RemoteId,
+
        actual: FeatureLevel,
+
        minimum: FeatureLevel,
+
    },
}

impl Repository {
modified crates/radicle/src/storage/refs.rs
@@ -281,19 +281,38 @@ where
///
/// Feature levels are monotonic, in the sense that a greater feature level
/// encompasses all the features of smaller ones.
-
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
+
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub enum FeatureLevel {
-
    /// References are stored without additional metadata.
+
    /// The lowest feature level, with least security. It is vulnerable to
+
    /// graft attacks and replay attacks.
    #[default]
    None,
-
    /// Introduced in Radicle 1.1.0, in commit
-
    /// `989edacd564fa658358f5ccfd08c243c5ebd8cda`,
-
    /// this requires [`IDENTITY_ROOT`].
+

+
    #[cfg_attr(
+
        feature = "schemars",
+
        schemars(description = "\
+
        An intermediate feature level, which protects against graft attacks \
+
        but is vulnerable to replay attacks. \
+
        Introduced in Radicle 1.1.0, in commit \
+
        `989edacd564fa658358f5ccfd08c243c5ebd8cda`.\
+
    ")
+
    )]
+
    /// Requires [`IDENTITY_ROOT`].
    Root,
-
    /// Introduced in Radicle 1.7.0, in commit
-
    /// `d3bc868e84c334f113806df1737f52cc57c5453d`,
-
    /// this requires [`SIGREFS_PARENT`].
+

+
    #[cfg_attr(
+
        feature = "schemars",
+
        schemars(description = "\
+
        The highest feature level known, which protects against graft attacks \
+
        and replay attacks. \
+
        Introduced in Radicle 1.7.0, in commit \
+
        `d3bc868e84c334f113806df1737f52cc57c5453d`.\
+
    ")
+
    )]
+
    /// Requires [`SIGREFS_PARENT`].
    Parent,
}