From e245e3115bcb644ebf8ae99338c6061b397dddd5 Mon Sep 17 00:00:00 2001 From: Lorenz Leutgeb Date: Sun, 22 Mar 2026 19:07:43 +0100 Subject: [PATCH] fetch: Configure Minimum Feature Level Add a configuration knob `node.fetch.signedReferences.featureLevel.minimum` to allow node operators to enforce more strict verification when fetching Signed References. --- CHANGELOG.md | 4 ++ crates/radicle-cli/examples/rad-config.md | 50 +++++++++++++++ crates/radicle-fetch/src/lib.rs | 10 +-- crates/radicle-fetch/src/state.rs | 32 +++++++++- crates/radicle-node/src/runtime.rs | 6 +- crates/radicle-node/src/worker.rs | 9 ++- crates/radicle-node/src/worker/fetch.rs | 14 +++-- crates/radicle/src/node/config.rs | 76 +++++++++++++++++++++++ crates/radicle/src/storage/git.rs | 10 ++- crates/radicle/src/storage/refs.rs | 35 ++++++++--- 10 files changed, 217 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca80ecef..c5d4f68d 100644 --- a/CHANGELOG.md +++ b/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//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 diff --git a/crates/radicle-cli/examples/rad-config.md b/crates/radicle-cli/examples/rad-config.md index 3a991afe..bc984af4 100644 --- a/crates/radicle-cli/examples/rad-config.md +++ b/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" + } + ] } } } diff --git a/crates/radicle-fetch/src/lib.rs b/crates/radicle-fetch/src/lib.rs index 24fd89c4..cff3d47e 100644 --- a/crates/radicle-fetch/src/lib.rs +++ b/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( handle: &mut Handle, - limit: FetchLimit, + config: Config, remote: PublicKey, refs_at: Option>, ) -> Result @@ -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( handle: &mut Handle, - limit: FetchLimit, + config: Config, remote: PublicKey, ) -> Result 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(); diff --git a/crates/radicle-fetch/src/state.rs b/crates/radicle-fetch/src/state.rs index c7f378fd..9bba498f 100644 --- a/crates/radicle-fetch/src/state.rs +++ b/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, handshake: &Handshake, - limit: FetchLimit, + config: Config, remote: PublicKey, refs_at: Option>, ) -> Result @@ -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())? diff --git a/crates/radicle-node/src/runtime.rs b/crates/radicle-node/src/runtime.rs index 6ee3734a..23c92b4a 100644 --- a/crates/radicle-node/src/runtime.rs +++ b/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(), }; diff --git a/crates/radicle-node/src/worker.rs b/crates/radicle-node/src/worker.rs index 52eaa955..fecd5ea1 100644 --- a/crates/radicle-node/src/worker.rs +++ b/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 { 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, )?; diff --git a/crates/radicle-node/src/worker/fetch.rs b/crates/radicle-node/src/worker/fetch.rs index f3fb13ed..5e79512c 100644 --- a/crates/radicle-node/src/worker/fetch.rs +++ b/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>, ) -> Result { 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)) } }; diff --git a/crates/radicle/src/node/config.rs b/crates/radicle/src/node/config.rs index b9815e07..52561227 100644 --- a/crates/radicle/src/node/config.rs +++ b/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 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, @@ -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 + ); + } } diff --git a/crates/radicle/src/storage/git.rs b/crates/radicle/src/storage/git.rs index 435b6624..02e86de6 100644 --- a/crates/radicle/src/storage/git.rs +++ b/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 { diff --git a/crates/radicle/src/storage/refs.rs b/crates/radicle/src/storage/refs.rs index 5ecadb57..3161270d 100644 --- a/crates/radicle/src/storage/refs.rs +++ b/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, }