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.
This commit is contained in:
Lorenz Leutgeb 2026-03-22 19:07:43 +01:00 committed by Fintan Halpenny
parent 47063057a8
commit e245e3115b
10 changed files with 217 additions and 29 deletions

View File

@ -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

View File

@ -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"
}
]
}
}
}

View File

@ -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();

View File

@ -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())?

View File

@ -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(),
};

View File

@ -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,
)?;

View File

@ -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))
}
};

View File

@ -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
);
}
}

View File

@ -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 {

View File

@ -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,
}