Skip to content

Commit ebd1b2f

Browse files
committed
feat(metadata): add metadata signature verification
1 parent 5ce4514 commit ebd1b2f

10 files changed

Lines changed: 224 additions & 28 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/soar-cli/src/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,10 @@ pub enum Commands {
356356
#[arg(required = false, short, long)]
357357
yes: bool,
358358

359+
/// Skip checksum verification before running
360+
#[arg(required = false, long)]
361+
no_verify: bool,
362+
359363
/// Command to execute
360364
#[arg(required = true)]
361365
command: Vec<String>,

crates/soar-cli/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ async fn handle_cli() -> SoarResult<()> {
304304
} => inspect_log(&package, InspectType::BuildScript).await?,
305305
cli::Commands::Run {
306306
yes,
307+
no_verify,
307308
command,
308309
pkg_id,
309310
repo_name,
@@ -312,6 +313,7 @@ async fn handle_cli() -> SoarResult<()> {
312313
&ctx,
313314
command.as_ref(),
314315
yes,
316+
no_verify,
315317
repo_name.as_deref(),
316318
pkg_id.as_deref(),
317319
)

crates/soar-cli/src/run.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub async fn run_package(
77
ctx: &SoarContext,
88
command: &[String],
99
yes: bool,
10+
no_verify: bool,
1011
repo_name: Option<&str>,
1112
pkg_id: Option<&str>,
1213
) -> SoarResult<i32> {
@@ -17,7 +18,7 @@ pub async fn run_package(
1718
&[]
1819
};
1920

20-
let result = run::prepare_run(ctx, package_name, repo_name, pkg_id).await?;
21+
let result = run::prepare_run(ctx, package_name, repo_name, pkg_id, no_verify).await?;
2122

2223
let output_path = match result {
2324
PrepareRunResult::Ready(path) => path,
@@ -33,9 +34,14 @@ pub async fn run_package(
3334
};
3435

3536
// Re-run with selected package
36-
let result =
37-
run::prepare_run(ctx, package_name, Some(&pkg.repo_name), Some(&pkg.pkg_id))
38-
.await?;
37+
let result = run::prepare_run(
38+
ctx,
39+
package_name,
40+
Some(&pkg.repo_name),
41+
Some(&pkg.pkg_id),
42+
no_verify,
43+
)
44+
.await?;
3945

4046
match result {
4147
PrepareRunResult::Ready(path) => path,

crates/soar-config/src/config.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,12 @@ impl Config {
440440
if repo.pubkey.is_none() && repo.name.as_str() == "soarpkgs" {
441441
repo.pubkey = Some(SOARPKGS_PUBKEY.to_string())
442442
}
443+
444+
let explicitly_enabled = self.signature_verification == Some(true)
445+
|| repo.signature_verification == Some(true);
446+
if explicitly_enabled && repo.pubkey.is_none() {
447+
return Err(ConfigError::MissingPubkey(repo.name.clone()));
448+
}
443449
}
444450

445451
Ok(())
@@ -685,6 +691,23 @@ mod tests {
685691
assert!(matches!(result, Err(ConfigError::ReservedRepositoryName)));
686692
}
687693

694+
#[test]
695+
fn test_config_resolve_signature_verification_without_pubkey() {
696+
let mut config = Config::default_config::<&str>(&[]);
697+
config.repositories.push(Repository {
698+
name: "needs-key".to_string(),
699+
url: "https://example.com".to_string(),
700+
desktop_integration: None,
701+
pubkey: None,
702+
enabled: Some(true),
703+
signature_verification: Some(true),
704+
sync_interval: None,
705+
});
706+
707+
let result = config.resolve();
708+
assert!(matches!(result, Err(ConfigError::MissingPubkey(_))));
709+
}
710+
688711
#[test]
689712
fn test_config_resolve_duplicate_repo() {
690713
let mut config = Config::default_config::<&str>(&[]);

crates/soar-config/src/error.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ pub enum ConfigError {
8282
)]
8383
DuplicateRepositoryName(String),
8484

85+
#[error("Repository '{0}' has signature verification enabled but no pubkey configured")]
86+
#[diagnostic(
87+
code(soar_config::missing_pubkey),
88+
help("Provide a pubkey for the repository or disable signature_verification")
89+
)]
90+
MissingPubkey(String),
91+
8592
#[error(transparent)]
8693
#[diagnostic(code(soar_config::io))]
8794
IoError(#[from] std::io::Error),

crates/soar-operations/src/run.rs

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ use soar_core::{
99
};
1010
use soar_db::repository::metadata::MetadataRepository;
1111
use soar_dl::{download::Download, oci::OciDownload, types::OverwriteMode};
12-
use soar_events::SoarEvent;
1312
use soar_utils::hash::calculate_checksum;
1413
use tracing::debug;
1514

@@ -27,6 +26,7 @@ pub async fn prepare_run(
2726
package_name: &str,
2827
repo_name: Option<&str>,
2928
pkg_id: Option<&str>,
29+
no_verify: bool,
3030
) -> SoarResult<PrepareRunResult> {
3131
debug!(package_name = package_name, "preparing run");
3232
let config = ctx.config();
@@ -39,9 +39,6 @@ pub async fn prepare_run(
3939
let version = query.version.as_deref();
4040

4141
let output_path = cache_bin.join(package_name);
42-
if output_path.exists() {
43-
return Ok(PrepareRunResult::Ready(output_path));
44-
}
4542

4643
let metadata_mgr = ctx.metadata_manager().await?;
4744

@@ -108,6 +105,34 @@ pub async fn prepare_run(
108105

109106
let package = packages.into_iter().next().unwrap().resolve(version);
110107

108+
// Refuse to execute a package whose integrity cannot be checked. OCI
109+
// artifacts are digest-verified during download, so they are exempt.
110+
if !no_verify && package.bsum.is_none() && package.ghcr_blob.is_none() {
111+
return Err(SoarError::Custom(format!(
112+
"Refusing to run {}#{}: no checksum to verify integrity (use --no-verify to override)",
113+
package.pkg_name, package.pkg_id
114+
)));
115+
}
116+
117+
// Reuse a cached binary only after re-verifying it against the expected
118+
// checksum, so a stale or tampered cache entry is never executed blindly.
119+
if output_path.exists() {
120+
match package.bsum {
121+
Some(ref bsum) if !no_verify => {
122+
let checksum = calculate_checksum(&output_path)?;
123+
if checksum == *bsum {
124+
return Ok(PrepareRunResult::Ready(output_path));
125+
}
126+
debug!(
127+
package = %package.pkg_name,
128+
"cached binary checksum mismatch; re-downloading"
129+
);
130+
fs::remove_file(&output_path).ok();
131+
}
132+
_ => return Ok(PrepareRunResult::Ready(output_path)),
133+
}
134+
}
135+
111136
fs::create_dir_all(&cache_bin)
112137
.with_context(|| format!("creating directory {}", cache_bin.display()))?;
113138

@@ -119,22 +144,13 @@ pub async fn prepare_run(
119144
package.pkg_id.clone(),
120145
);
121146

122-
download_to_cache(&package, &output_path, &cache_bin, progress_callback)?;
123-
124-
// Checksum verification
125-
let checksum = calculate_checksum(&output_path)?;
126-
if let Some(ref bsum) = package.bsum {
127-
if checksum != *bsum {
128-
ctx.events().emit(SoarEvent::Log {
129-
level: soar_events::LogLevel::Warning,
130-
message: format!(
131-
"Checksum mismatch for {}: expected {}, got {}",
132-
package.pkg_name, bsum, checksum
133-
),
134-
});
135-
return Err(SoarError::InvalidChecksum);
136-
}
137-
}
147+
download_to_cache(
148+
&package,
149+
&output_path,
150+
&cache_bin,
151+
no_verify,
152+
progress_callback,
153+
)?;
138154

139155
Ok(PrepareRunResult::Ready(output_path))
140156
}
@@ -157,6 +173,7 @@ fn download_to_cache(
157173
package: &Package,
158174
output_path: &Path,
159175
cache_bin: &Path,
176+
no_verify: bool,
160177
progress_callback: Arc<dyn Fn(soar_dl::types::Progress) + Send + Sync>,
161178
) -> SoarResult<()> {
162179
if let Some(ref url) = package.ghcr_blob {
@@ -176,6 +193,11 @@ fn download_to_cache(
176193
.overwrite(OverwriteMode::Force)
177194
.extract(true)
178195
.extract_to(&extract_dir);
196+
if !no_verify {
197+
if let Some(ref bsum) = package.bsum {
198+
dl = dl.checksum(bsum.clone());
199+
}
200+
}
179201
dl = dl.progress(move |p| {
180202
cb(p);
181203
});

crates/soar-registry/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ categories.workspace = true
1111

1212
[dependencies]
1313
miette = { workspace = true }
14+
minisign-verify = { workspace = true }
1415
serde = { workspace = true }
1516
serde_json = { workspace = true }
1617
soar-config = { workspace = true }

crates/soar-registry/src/error.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,36 @@ pub enum RegistryError {
6969
)]
7070
MissingEtag,
7171

72+
#[error("Insecure repository URL: {0}")]
73+
#[diagnostic(
74+
code(soar_registry::insecure_url),
75+
help("Repository metadata must be served over https")
76+
)]
77+
InsecureUrl(String),
78+
79+
#[error("Could not fetch metadata signature for {repo}: {reason}")]
80+
#[diagnostic(
81+
code(soar_registry::signature_missing),
82+
help(
83+
"Signature verification is enabled but the detached signature could not be retrieved"
84+
)
85+
)]
86+
MetadataSignatureMissing { repo: String, reason: String },
87+
88+
#[error("Metadata signature verification failed for {repo}: {reason}")]
89+
#[diagnostic(
90+
code(soar_registry::signature_invalid),
91+
help("The metadata may be tampered with or signed by a different key")
92+
)]
93+
MetadataSignatureInvalid { repo: String, reason: String },
94+
95+
#[error("Metadata exceeds the maximum decompressed size of {limit} bytes")]
96+
#[diagnostic(
97+
code(soar_registry::metadata_too_large),
98+
help("The metadata file is unexpectedly large and may be a decompression bomb")
99+
)]
100+
MetadataTooLarge { limit: u64 },
101+
72102
#[error("{0}")]
73103
#[diagnostic(code(soar_registry::custom))]
74104
Custom(String),

0 commit comments

Comments
 (0)