forked from brl/citadel-tools
Compare commits
1 Commits
master
...
tuf_update
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e80644d4c |
1295
Cargo.lock
generated
1295
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ toml = "0.9"
|
||||
hex = "0.4"
|
||||
byteorder = "1"
|
||||
pwhash = "1.0"
|
||||
rand = "0.8"
|
||||
tempfile = "3"
|
||||
zbus = "5.9.0"
|
||||
anyhow = "1.0"
|
||||
@@ -24,4 +25,17 @@ log = "0.4"
|
||||
zbus_macros = "5.9"
|
||||
event-listener = "5.4"
|
||||
futures-timer = "3.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
rs-release = "0.1"
|
||||
glob = "0.3"
|
||||
serde_cbor = "0.11"
|
||||
ed25519-dalek = { version = "2.2", features = ["pem", "rand_core"] }
|
||||
base64ct = "=1.7.3"
|
||||
ureq = { version = "3.1" }
|
||||
sha2 = "0.10"
|
||||
nix = "0.30"
|
||||
dialoguer = "0.12"
|
||||
indicatif = "0.18"
|
||||
serde_json = "1.0"
|
||||
chrono = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
@@ -136,8 +136,10 @@ fn compare_boot_partitions(a: Option<Partition>, b: Partition) -> Option<Partiti
|
||||
}
|
||||
|
||||
// Compare versions and channels
|
||||
let a_v = a.metainfo().version();
|
||||
let b_v = b.metainfo().version();
|
||||
let bind_a = a.metainfo();
|
||||
let bind_b = b.metainfo();
|
||||
let a_v = bind_a.version();
|
||||
let b_v = bind_b.version();
|
||||
|
||||
// Compare versions only if channels match
|
||||
if a.metainfo().channel() == b.metainfo().channel() {
|
||||
|
||||
949
citadel-tool/src/fetch/client.rs
Normal file
949
citadel-tool/src/fetch/client.rs
Normal file
@@ -0,0 +1,949 @@
|
||||
use super::config::Config;
|
||||
use super::keyring::ChannelKey;
|
||||
use super::metadata::*;
|
||||
use anyhow::{Context, Result};
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use libcitadel::ImageHeader;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryInto;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const METADATA_CACHE_DIR: &str = "/storage/citadel-state/tuf-cache/metadata";
|
||||
const TARGETS_CACHE_DIR: &str = "/storage/citadel-state/tuf-cache/targets";
|
||||
const EMBEDDED_ROOT: &str = "/etc/citadel/root.json";
|
||||
|
||||
pub struct TufClient {
|
||||
config: Config,
|
||||
metadata_dir: PathBuf,
|
||||
targets_dir: PathBuf,
|
||||
root: Option<SignedMetadata<RootMetadata>>,
|
||||
timestamp: Option<SignedMetadata<TimestampMetadata>>,
|
||||
snapshot: Option<SignedMetadata<SnapshotMetadata>>,
|
||||
targets: Option<SignedMetadata<TargetsMetadata>>,
|
||||
channel_targets: HashMap<String, SignedMetadata<TargetsMetadata>>,
|
||||
os_release_info: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateInfo {
|
||||
pub component: String,
|
||||
pub current_version: String,
|
||||
pub new_version: String,
|
||||
pub target_path: String,
|
||||
pub download_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelInfo {
|
||||
pub name: String,
|
||||
pub key_id: String,
|
||||
}
|
||||
|
||||
impl TufClient {
|
||||
pub fn new(config: &Config) -> Result<Self> {
|
||||
let metadata_dir = PathBuf::from(METADATA_CACHE_DIR);
|
||||
let targets_dir = PathBuf::from(TARGETS_CACHE_DIR);
|
||||
|
||||
fs::create_dir_all(&metadata_dir)?;
|
||||
fs::create_dir_all(&targets_dir)?;
|
||||
|
||||
let root = load_or_bootstrap_root(&metadata_dir)?;
|
||||
let os_release_info =
|
||||
super::config::parse_conf_file(Path::new("/etc/os-release")).unwrap_or_default();
|
||||
|
||||
Ok(TufClient {
|
||||
config: config.clone(),
|
||||
metadata_dir,
|
||||
targets_dir,
|
||||
root: Some(root),
|
||||
timestamp: None,
|
||||
snapshot: None,
|
||||
targets: None,
|
||||
channel_targets: HashMap::new(),
|
||||
os_release_info,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh_metadata(&mut self) -> Result<()> {
|
||||
log::info!("Refreshing all metadata");
|
||||
self.update_root()?;
|
||||
self.update_timestamp()?;
|
||||
self.update_snapshot()?;
|
||||
self.update_targets()?;
|
||||
log::info!("Metadata refresh complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_root(&mut self) -> Result<()> {
|
||||
log::info!("Updating root metadata");
|
||||
let mut current_root = self.root.clone().unwrap();
|
||||
let mut version = current_root.signed.version;
|
||||
|
||||
loop {
|
||||
let next_version = version + 1;
|
||||
log::debug!("Attempting to fetch root metadata version {}", next_version);
|
||||
let url = format!(
|
||||
"{}/{}.root.json",
|
||||
self.config.repository_url(),
|
||||
next_version
|
||||
);
|
||||
|
||||
match fetch_url(&url) {
|
||||
Ok(content) => {
|
||||
log::debug!(
|
||||
"Successfully fetched root metadata version {}",
|
||||
next_version
|
||||
);
|
||||
let new_root: SignedMetadata<RootMetadata> =
|
||||
serde_json::from_str(&content).context("Failed to parse root.json")?;
|
||||
|
||||
if new_root.signed.version != next_version {
|
||||
anyhow::bail!("Root version mismatch");
|
||||
}
|
||||
|
||||
check_expiry(&new_root.signed.expires)?;
|
||||
|
||||
// Verify with current root's keys
|
||||
log::debug!("Verifying new root with old root keys");
|
||||
self.verify_root_signature(&new_root, ¤t_root)?;
|
||||
|
||||
// Update for next iteration
|
||||
current_root = new_root.clone();
|
||||
version = next_version;
|
||||
|
||||
// Save to cache
|
||||
let cache_path = self.metadata_dir.join("root.json");
|
||||
fs::write(&cache_path, &content)?;
|
||||
log::debug!("Saved new root metadata to cache");
|
||||
|
||||
// Update self.root
|
||||
self.root = Some(new_root);
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Failed to fetch root version {}: {}. Assuming this is the latest version.",
|
||||
next_version,
|
||||
e
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_timestamp(&mut self) -> Result<()> {
|
||||
log::info!("Updating timestamp metadata");
|
||||
let url = format!("{}/timestamp.json", self.config.repository_url());
|
||||
let content = fetch_url(&url).context("Failed to fetch timestamp.json")?;
|
||||
|
||||
let timestamp: SignedMetadata<TimestampMetadata> =
|
||||
serde_json::from_str(&content).context("Failed to parse timestamp.json")?;
|
||||
|
||||
log::debug!("Verifying timestamp metadata signature");
|
||||
self.verify_role_signature(×tamp, "timestamp")?;
|
||||
check_expiry(×tamp.signed.expires)?;
|
||||
|
||||
if let Some(cached) = &self.timestamp {
|
||||
if timestamp.signed.version < cached.signed.version {
|
||||
log::error!(
|
||||
"Timestamp rollback detected! Cached version: {}, new version: {}",
|
||||
cached.signed.version,
|
||||
timestamp.signed.version
|
||||
);
|
||||
anyhow::bail!("Timestamp rollback detected");
|
||||
}
|
||||
}
|
||||
|
||||
let cache_path = self.metadata_dir.join("timestamp.json");
|
||||
fs::write(&cache_path, &content)?;
|
||||
log::debug!("Saved new timestamp metadata to cache");
|
||||
|
||||
self.timestamp = Some(timestamp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_snapshot(&mut self) -> Result<()> {
|
||||
log::info!("Updating snapshot metadata");
|
||||
let timestamp = self.timestamp.as_ref().unwrap();
|
||||
let snapshot_meta = timestamp
|
||||
.signed
|
||||
.meta
|
||||
.get("snapshot.json")
|
||||
.ok_or_else(|| anyhow::anyhow!("No snapshot.json in timestamp"))?;
|
||||
|
||||
let url = format!("{}/snapshot.json", self.config.repository_url());
|
||||
let content = fetch_url(&url).context("Failed to fetch snapshot.json")?;
|
||||
|
||||
if let Some(hashes) = &snapshot_meta.hashes {
|
||||
if let Some(expected) = hashes.get("sha256") {
|
||||
let actual = hex::encode(Sha256::digest(content.as_bytes()));
|
||||
if &actual != expected {
|
||||
log::error!(
|
||||
"Snapshot hash mismatch! Expected: {}, actual: {}",
|
||||
expected,
|
||||
actual
|
||||
);
|
||||
anyhow::bail!("Snapshot hash mismatch");
|
||||
}
|
||||
log::debug!("Snapshot hash verified");
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot: SignedMetadata<SnapshotMetadata> =
|
||||
serde_json::from_str(&content).context("Failed to parse snapshot.json")?;
|
||||
|
||||
log::debug!("Verifying snapshot metadata signature");
|
||||
self.verify_role_signature(&snapshot, "snapshot")?;
|
||||
check_expiry(&snapshot.signed.expires)?;
|
||||
|
||||
let cache_path = self.metadata_dir.join("snapshot.json");
|
||||
fs::write(&cache_path, &content)?;
|
||||
log::debug!("Saved new snapshot metadata to cache");
|
||||
|
||||
self.snapshot = Some(snapshot);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_targets(&mut self) -> Result<()> {
|
||||
log::info!("Updating targets metadata");
|
||||
let snapshot = self.snapshot.as_ref().unwrap();
|
||||
let _targets_meta = snapshot
|
||||
.signed
|
||||
.meta
|
||||
.get("targets.json")
|
||||
.ok_or_else(|| anyhow::anyhow!("No targets.json in snapshot"))?;
|
||||
|
||||
let url = format!("{}/targets.json", self.config.repository_url());
|
||||
let content = fetch_url(&url).context("Failed to fetch targets.json")?;
|
||||
|
||||
let targets: SignedMetadata<TargetsMetadata> =
|
||||
serde_json::from_str(&content).context("Failed to parse targets.json")?;
|
||||
|
||||
log::debug!("Verifying targets metadata signature");
|
||||
self.verify_role_signature(&targets, "targets")?;
|
||||
check_expiry(&targets.signed.expires)?;
|
||||
|
||||
let cache_path = self.metadata_dir.join("targets.json");
|
||||
fs::write(&cache_path, &content)?;
|
||||
log::debug!("Saved new targets metadata to cache");
|
||||
|
||||
self.targets = Some(targets);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_channel_targets(&mut self, channel: &str) -> Result<()> {
|
||||
if self.channel_targets.contains_key(channel) {
|
||||
log::debug!("Channel targets for '{}' already loaded", channel);
|
||||
return Ok(());
|
||||
}
|
||||
log::info!("Loading channel targets for '{}'", channel);
|
||||
|
||||
let url = format!("{}/{}.json", self.config.repository_url(), channel);
|
||||
let content =
|
||||
fetch_url(&url).with_context(|| format!("Failed to fetch {}.json", channel))?;
|
||||
|
||||
let channel_targets: SignedMetadata<TargetsMetadata> =
|
||||
serde_json::from_str(&content).context("Failed to parse channel targets")?;
|
||||
|
||||
log::debug!(
|
||||
"Verifying channel targets metadata signature for '{}'",
|
||||
channel
|
||||
);
|
||||
self.verify_delegation_signature(&channel_targets, channel)?;
|
||||
check_expiry(&channel_targets.signed.expires)?;
|
||||
|
||||
let cache_path = self.metadata_dir.join(format!("{}.json", channel));
|
||||
fs::write(&cache_path, &content)?;
|
||||
log::debug!(
|
||||
"Saved new channel targets metadata for '{}' to cache",
|
||||
channel
|
||||
);
|
||||
|
||||
self.channel_targets
|
||||
.insert(channel.to_string(), channel_targets);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_for_updates(&mut self, channel: &str) -> Result<Vec<UpdateInfo>> {
|
||||
log::info!("Checking for updates for channel '{}'", channel);
|
||||
self.load_channel_targets(channel)?;
|
||||
|
||||
let channel_targets = self.channel_targets.get(channel).unwrap();
|
||||
|
||||
let mut updates = Vec::new();
|
||||
|
||||
for (target_path, info) in &channel_targets.signed.targets {
|
||||
log::debug!("Checking target: {}", target_path);
|
||||
let custom = match &info.custom {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
log::debug!("Target {} has no custom info, skipping", target_path);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let component = &custom.image_type;
|
||||
|
||||
log::debug!("Target component: {}", component);
|
||||
|
||||
let current_version = match component.as_str() {
|
||||
"rootfs" => {
|
||||
let version = self
|
||||
.os_release_info
|
||||
.get("CITADEL_ROOTFS_VERSION")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("0.0.0")
|
||||
.to_string();
|
||||
log::debug!("Current rootfs version from os-release: {}", version);
|
||||
version
|
||||
}
|
||||
"kernel" => {
|
||||
let base_path = format!("/storage/resources/{}", self.config.channel);
|
||||
|
||||
// Try to find any citadel-kernel*.img file and pick the highest version
|
||||
if let Some((image_path, version)) =
|
||||
find_highest_version_image(&base_path, "citadel-kernel")
|
||||
{
|
||||
log::debug!(
|
||||
"Found kernel image at {} with version {}",
|
||||
image_path.display(),
|
||||
version
|
||||
);
|
||||
version
|
||||
} else {
|
||||
log::debug!("No kernel image found in {}", base_path);
|
||||
"0.0.0".to_string()
|
||||
}
|
||||
}
|
||||
"extra" => {
|
||||
let base_path = format!("/storage/resources/{}", self.config.channel);
|
||||
|
||||
// Try to find any citadel-extra*.img file and pick the highest version
|
||||
if let Some((image_path, version)) =
|
||||
find_highest_version_image(&base_path, "citadel-extra")
|
||||
{
|
||||
log::debug!(
|
||||
"Found extra image at {} with version {}",
|
||||
image_path.display(),
|
||||
version
|
||||
);
|
||||
version
|
||||
} else {
|
||||
log::debug!("No extra image found in {}", base_path);
|
||||
"0.0.0".to_string()
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
log::warn!("Unknown component type: {}", component);
|
||||
"0.0.0".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"Comparing new version {} with current version {}",
|
||||
&custom.version,
|
||||
¤t_version
|
||||
);
|
||||
if version_gt(&custom.version, ¤t_version) {
|
||||
// Check min_version requirement
|
||||
if let Some(min) = &custom.min_version {
|
||||
if version_gt(min, ¤t_version) {
|
||||
log::warn!("Update for {} available, but current version {} is less than minimum required version {}. Skipping.", component, ¤t_version, min);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Found update for {}: {} -> {}",
|
||||
component,
|
||||
¤t_version,
|
||||
&custom.version
|
||||
);
|
||||
updates.push(UpdateInfo {
|
||||
component: component.to_string(),
|
||||
current_version: current_version.to_string(),
|
||||
new_version: custom.version.clone(),
|
||||
target_path: target_path.clone(),
|
||||
download_size: Some(info.length),
|
||||
});
|
||||
} else {
|
||||
log::debug!("Component {} is up to date", component);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(updates)
|
||||
}
|
||||
|
||||
pub fn download_target(&mut self, channel: &str, target_path: &str) -> Result<PathBuf> {
|
||||
log::info!(
|
||||
"Downloading target '{}' from channel '{}'",
|
||||
target_path,
|
||||
channel
|
||||
);
|
||||
self.load_channel_targets(channel)?;
|
||||
|
||||
let channel_targets = self.channel_targets.get(channel).unwrap();
|
||||
let target_info = channel_targets
|
||||
.signed
|
||||
.targets
|
||||
.get(target_path)
|
||||
.ok_or_else(|| anyhow::anyhow!("Target not found: {}", target_path))?;
|
||||
|
||||
let expected_hash = target_info
|
||||
.hashes
|
||||
.get("sha256")
|
||||
.ok_or_else(|| anyhow::anyhow!("No sha256 hash for target"))?;
|
||||
|
||||
let filename = Path::new(target_path)
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy();
|
||||
|
||||
let url = format!("{}/targets/{}", self.config.repository_url(), target_path);
|
||||
let local_path = self.targets_dir.join(&*filename);
|
||||
|
||||
log::debug!("Downloading from {} to {}", url, local_path.display());
|
||||
download_file(&url, &local_path, target_info.length)?;
|
||||
|
||||
// Verify hash
|
||||
log::debug!(
|
||||
"Verifying hash for downloaded file {}",
|
||||
local_path.display()
|
||||
);
|
||||
let actual_hash = hash_file(&local_path)?;
|
||||
if actual_hash != *expected_hash {
|
||||
fs::remove_file(&local_path).ok();
|
||||
log::error!(
|
||||
"Hash mismatch for {}! Expected: {}, actual: {}",
|
||||
local_path.display(),
|
||||
expected_hash,
|
||||
actual_hash
|
||||
);
|
||||
anyhow::bail!(
|
||||
"Hash mismatch!\n Expected: {}\n Actual: {}",
|
||||
expected_hash,
|
||||
actual_hash
|
||||
);
|
||||
}
|
||||
log::info!("Hash verified for {}", local_path.display());
|
||||
|
||||
Ok(local_path)
|
||||
}
|
||||
|
||||
pub fn list_channels(&self) -> Result<Vec<ChannelInfo>> {
|
||||
log::info!("Listing available channels");
|
||||
let targets = self
|
||||
.targets
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Targets not loaded"))?;
|
||||
|
||||
let mut channels = Vec::new();
|
||||
|
||||
if let Some(delegations) = &targets.signed.delegations {
|
||||
for role in &delegations.roles {
|
||||
let key_id = role.keyids.first().cloned().unwrap_or_default();
|
||||
log::debug!("Found channel: {} with key ID: {}", role.name, key_id);
|
||||
channels.push(ChannelInfo {
|
||||
name: role.name.clone(),
|
||||
key_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!("Found {} channels", channels.len());
|
||||
Ok(channels)
|
||||
}
|
||||
|
||||
pub fn get_channel_key(&self, channel: &str) -> Result<ChannelKey> {
|
||||
log::info!("Getting channel key for '{}'", channel);
|
||||
let targets = self
|
||||
.targets
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Targets not loaded"))?;
|
||||
|
||||
let delegations = targets
|
||||
.signed
|
||||
.delegations
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No delegations in targets"))?;
|
||||
|
||||
let role = delegations
|
||||
.roles
|
||||
.iter()
|
||||
.find(|r| r.name == channel)
|
||||
.ok_or_else(|| anyhow::anyhow!("Channel '{}' not found", channel))?;
|
||||
|
||||
let key_id = role
|
||||
.keyids
|
||||
.first()
|
||||
.ok_or_else(|| anyhow::anyhow!("No key for channel '{}'", channel))?;
|
||||
|
||||
let key = delegations
|
||||
.keys
|
||||
.get(key_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Key {} not found", key_id))?;
|
||||
|
||||
log::debug!(
|
||||
"Retrieved key for channel '{}': key ID = {}, public key = {}...",
|
||||
channel,
|
||||
key_id,
|
||||
&key.keyval.public[..16]
|
||||
);
|
||||
Ok(ChannelKey {
|
||||
key_id: key_id.clone(),
|
||||
public_key: key.keyval.public.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_root_signature(
|
||||
&self,
|
||||
new_root: &SignedMetadata<RootMetadata>,
|
||||
old_root: &SignedMetadata<RootMetadata>,
|
||||
) -> Result<()> {
|
||||
log::debug!("Verifying root signature");
|
||||
// Verify with old root's keys first
|
||||
let role_def = old_root
|
||||
.signed
|
||||
.roles
|
||||
.get("root")
|
||||
.ok_or_else(|| anyhow::anyhow!("No root role in root.json"))?;
|
||||
|
||||
log::debug!("Verifying new root with old root's keys");
|
||||
verify_signatures(
|
||||
new_root,
|
||||
&role_def.keyids,
|
||||
role_def.threshold,
|
||||
&old_root.signed.keys,
|
||||
)?;
|
||||
|
||||
// Then verify with new root's keys
|
||||
let new_role_def = new_root
|
||||
.signed
|
||||
.roles
|
||||
.get("root")
|
||||
.ok_or_else(|| anyhow::anyhow!("No root role in new root.json"))?;
|
||||
|
||||
log::debug!("Verifying new root with new root's keys");
|
||||
verify_signatures(
|
||||
new_root,
|
||||
&new_role_def.keyids,
|
||||
new_role_def.threshold,
|
||||
&new_root.signed.keys,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_role_signature<T: Serialize>(
|
||||
&self,
|
||||
signed: &SignedMetadata<T>,
|
||||
role: &str,
|
||||
) -> Result<()> {
|
||||
log::debug!("Verifying role signature for role: {}", role);
|
||||
let root = self
|
||||
.root
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Root not loaded"))?;
|
||||
|
||||
let role_def = root
|
||||
.signed
|
||||
.roles
|
||||
.get(role)
|
||||
.ok_or_else(|| anyhow::anyhow!("Role '{}' not found", role))?;
|
||||
|
||||
log::debug!(
|
||||
"Role '{}' found. Key IDs: {:?}, Threshold: {}",
|
||||
role,
|
||||
role_def.keyids,
|
||||
role_def.threshold
|
||||
);
|
||||
verify_signatures(
|
||||
signed,
|
||||
&role_def.keyids,
|
||||
role_def.threshold,
|
||||
&root.signed.keys,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_delegation_signature<T: Serialize>(
|
||||
&self,
|
||||
signed: &SignedMetadata<T>,
|
||||
delegation_name: &str,
|
||||
) -> Result<()> {
|
||||
log::debug!(
|
||||
"Verifying delegation signature for delegation: {}",
|
||||
delegation_name
|
||||
);
|
||||
let targets = self
|
||||
.targets
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Targets not loaded"))?;
|
||||
|
||||
let delegations = targets
|
||||
.signed
|
||||
.delegations
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No delegations"))?;
|
||||
|
||||
let role = delegations
|
||||
.roles
|
||||
.iter()
|
||||
.find(|r| r.name == delegation_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Delegation '{}' not found", delegation_name))?;
|
||||
|
||||
log::debug!(
|
||||
"Delegation '{}' found. Key IDs: {:?}, Threshold: {}",
|
||||
delegation_name,
|
||||
role.keyids,
|
||||
role.threshold
|
||||
);
|
||||
log::debug!(
|
||||
"Available delegation keys: {:?}",
|
||||
delegations.keys.keys().collect::<Vec<_>>()
|
||||
);
|
||||
verify_signatures(signed, &role.keyids, role.threshold, &delegations.keys)
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_signatures<T: Serialize>(
|
||||
signed: &SignedMetadata<T>,
|
||||
authorized_keyids: &[String],
|
||||
threshold: u32,
|
||||
keys: &HashMap<String, TufKey>,
|
||||
) -> Result<()> {
|
||||
let canonical_bytes = canonicalize_json(&signed.signed)?;
|
||||
log::debug!(
|
||||
"Canonical JSON (first 200 bytes): {:?}",
|
||||
&canonical_bytes[..std::cmp::min(200, canonical_bytes.len())]
|
||||
);
|
||||
let authorized_set: std::collections::HashSet<&String> = authorized_keyids.iter().collect();
|
||||
|
||||
let mut valid_count = 0u32;
|
||||
|
||||
log::debug!("Verifying signatures. Threshold: {}", threshold);
|
||||
log::debug!("Authorized key IDs: {:?}", authorized_keyids);
|
||||
|
||||
for sig in &signed.signatures {
|
||||
log::debug!("Processing signature with key ID: {}", sig.keyid);
|
||||
|
||||
if !authorized_set.contains(&sig.keyid) {
|
||||
log::debug!(
|
||||
"Signature with key ID {} is not in the authorized set, skipping.",
|
||||
sig.keyid
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let tuf_key = match keys.get(&sig.keyid) {
|
||||
Some(k) => {
|
||||
log::debug!("Found key for ID {}", sig.keyid);
|
||||
k
|
||||
}
|
||||
None => {
|
||||
log::warn!("Key not found for ID {}", sig.keyid);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if tuf_key.keytype != "ed25519" {
|
||||
log::warn!(
|
||||
"Unsupported key type for key ID {}: {}",
|
||||
sig.keyid,
|
||||
tuf_key.keytype
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let pub_bytes = match hex::decode(&tuf_key.keyval.public) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to decode public key for key ID {}: {}",
|
||||
sig.keyid,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let pub_array: [u8; 32] = match pub_bytes.try_into() {
|
||||
Ok(a) => a,
|
||||
Err(_) => {
|
||||
log::warn!("Public key for key ID {} has incorrect length", sig.keyid);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let verifying_key = match VerifyingKey::from_bytes(&pub_array) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to construct verifying key from public key for key ID {}: {}",
|
||||
sig.keyid,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let sig_bytes = match hex::decode(&sig.sig) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to decode signature for key ID {}: {}", sig.keyid, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let sig_array: [u8; 64] = match sig_bytes.try_into() {
|
||||
Ok(a) => a,
|
||||
Err(_) => {
|
||||
log::warn!("Signature for key ID {} has incorrect length", sig.keyid);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let signature = Signature::from_bytes(&sig_array);
|
||||
|
||||
if verifying_key.verify(&canonical_bytes, &signature).is_ok() {
|
||||
log::debug!("Signature verified successfully for key ID {}", sig.keyid);
|
||||
valid_count += 1;
|
||||
} else {
|
||||
log::warn!("Signature verification failed for key ID {}", sig.keyid);
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"Final verification count: valid_count={}, threshold={}",
|
||||
valid_count,
|
||||
threshold
|
||||
);
|
||||
if valid_count >= threshold {
|
||||
log::debug!(
|
||||
"Signature verification successful. Got {} valid signatures, threshold is {}",
|
||||
valid_count,
|
||||
threshold
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
log::error!(
|
||||
"Signature verification failed: got {} valid, need {}",
|
||||
valid_count,
|
||||
threshold
|
||||
);
|
||||
anyhow::bail!(
|
||||
"Signature verification failed: got {} valid, need {}",
|
||||
valid_count,
|
||||
threshold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_json<T: Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||
let json_value = serde_json::to_value(value)?;
|
||||
let canonical = sort_json_keys(json_value);
|
||||
Ok(serde_json::to_vec(&canonical)?)
|
||||
}
|
||||
|
||||
fn sort_json_keys(value: serde_json::Value) -> serde_json::Value {
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let sorted: BTreeMap<String, Value> = map
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, sort_json_keys(v)))
|
||||
.collect();
|
||||
Value::Object(sorted.into_iter().collect())
|
||||
}
|
||||
Value::Array(arr) => Value::Array(arr.into_iter().map(sort_json_keys).collect()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_or_bootstrap_root(metadata_dir: &Path) -> Result<SignedMetadata<RootMetadata>> {
|
||||
let cached_path = metadata_dir.join("root.json");
|
||||
|
||||
if cached_path.exists() {
|
||||
let content = fs::read_to_string(&cached_path)?;
|
||||
if let Ok(root) = serde_json::from_str(&content) {
|
||||
return Ok(root);
|
||||
}
|
||||
}
|
||||
|
||||
if Path::new(EMBEDDED_ROOT).exists() {
|
||||
let content = fs::read_to_string(EMBEDDED_ROOT)?;
|
||||
let root: SignedMetadata<RootMetadata> = serde_json::from_str(&content)?;
|
||||
fs::write(&cached_path, &content)?;
|
||||
return Ok(root);
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"No TUF root.json found at {} or {}",
|
||||
cached_path.display(),
|
||||
EMBEDDED_ROOT
|
||||
)
|
||||
}
|
||||
|
||||
fn check_expiry(expires: &str) -> Result<()> {
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
let expiry: DateTime<Utc> = expires.parse().context("Failed to parse expiry")?;
|
||||
|
||||
if expiry < Utc::now() {
|
||||
anyhow::bail!("Metadata expired: {}", expires);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_url(url: &str) -> Result<String> {
|
||||
let mut response = ureq::get(url)
|
||||
.call()
|
||||
.with_context(|| format!("Failed to fetch {}", url))?;
|
||||
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.with_context(|| format!("Failed to read response from {}", url))
|
||||
}
|
||||
|
||||
fn download_file(url: &str, dest: &Path, expected_size: u64) -> Result<()> {
|
||||
println!(" Downloading from {}", url);
|
||||
|
||||
// Create progress bar
|
||||
let pb = ProgressBar::new(expected_size);
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(" [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
|
||||
.unwrap()
|
||||
.progress_chars("#>-"),
|
||||
);
|
||||
|
||||
let mut response = ureq::get(url).call().context("Failed to download file")?;
|
||||
|
||||
let mut file =
|
||||
File::create(dest).with_context(|| format!("Failed to create file: {}", dest.display()))?;
|
||||
|
||||
let mut downloaded = 0u64;
|
||||
let mut buffer = [0u8; 8192];
|
||||
|
||||
let mut reader = response.body_mut().as_reader();
|
||||
|
||||
loop {
|
||||
match reader.read(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
file.write_all(&buffer[..n])?;
|
||||
downloaded += n as u64;
|
||||
pb.set_position(downloaded);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pb.finish_and_clear();
|
||||
|
||||
// Verify size
|
||||
if downloaded != expected_size {
|
||||
fs::remove_file(dest).ok();
|
||||
anyhow::bail!(
|
||||
"Size mismatch: expected {} bytes, got {}",
|
||||
expected_size,
|
||||
downloaded
|
||||
);
|
||||
}
|
||||
|
||||
println!(" ✓ Downloaded {} bytes", downloaded);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash_file(path: &Path) -> Result<String> {
|
||||
let mut file = File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 8192];
|
||||
|
||||
loop {
|
||||
let n = file.read(&mut buffer)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..n]);
|
||||
}
|
||||
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
}
|
||||
|
||||
/// Find the highest versioned image file matching the pattern
|
||||
/// e.g., citadel-kernel-*.img or citadel-extra-*.img
|
||||
fn find_highest_version_image(
|
||||
base_path: &str,
|
||||
component_prefix: &str,
|
||||
) -> Option<(PathBuf, String)> {
|
||||
use glob::glob;
|
||||
|
||||
let pattern = format!("{}/{}*.img", base_path, component_prefix);
|
||||
log::debug!("Searching for images with pattern: {}", pattern);
|
||||
|
||||
let mut best_path: Option<PathBuf> = None;
|
||||
let mut best_version = "0.0.0".to_string();
|
||||
|
||||
if let Ok(entries) = glob(&pattern) {
|
||||
for entry in entries.flatten() {
|
||||
log::debug!("Found image file: {}", entry.display());
|
||||
|
||||
// Try to read version from the image header
|
||||
if let Ok(header) = ImageHeader::from_file(&entry) {
|
||||
let version = if component_prefix.contains("kernel") {
|
||||
header
|
||||
.metainfo()
|
||||
.kernel_version()
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|| "0.0.0".to_string())
|
||||
} else {
|
||||
header.metainfo().version().to_string()
|
||||
};
|
||||
|
||||
log::debug!(" Image version: {}", version);
|
||||
|
||||
if version_gt(&version, &best_version) {
|
||||
best_version = version;
|
||||
best_path = Some(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
best_path.map(|path| {
|
||||
log::debug!(
|
||||
"Selected highest version image: {} (version: {})",
|
||||
path.display(),
|
||||
best_version
|
||||
);
|
||||
(path, best_version)
|
||||
})
|
||||
}
|
||||
|
||||
fn version_gt(a: &str, b: &str) -> bool {
|
||||
let parse = |s: &str| -> Vec<u32> { s.split('.').filter_map(|p| p.parse().ok()).collect() };
|
||||
|
||||
let va = parse(a);
|
||||
let vb = parse(b);
|
||||
|
||||
for (a, b) in va.iter().zip(vb.iter()) {
|
||||
if a > b {
|
||||
return true;
|
||||
}
|
||||
if a < b {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
va.len() > vb.len()
|
||||
}
|
||||
110
citadel-tool/src/fetch/config.rs
Normal file
110
citadel-tool/src/fetch/config.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const UPDATE_CONF_PATH: &str = "/etc/citadel/update.conf";
|
||||
const USER_CONF_PATH: &str = "/storage/citadel-state/citadel.conf";
|
||||
const OS_RELEASE_PATH: &str = "/etc/os-release";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub base_url: String,
|
||||
pub client: String,
|
||||
pub channel: String,
|
||||
pub min_root_version: u32,
|
||||
pub require_signatures: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let system_conf = parse_conf_file(Path::new(UPDATE_CONF_PATH)).unwrap_or_default();
|
||||
let user_conf = parse_conf_file(Path::new(USER_CONF_PATH)).unwrap_or_default();
|
||||
let os_release = parse_conf_file(Path::new(OS_RELEASE_PATH)).unwrap_or_default();
|
||||
|
||||
// User config overrides system config, which overrides os-release
|
||||
let channel = user_conf
|
||||
.get("UPDATE_CHANNEL")
|
||||
.or_else(|| system_conf.get("DEFAULT_CHANNEL"))
|
||||
.or_else(|| os_release.get("CITADEL_CHANNEL"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "stable".to_string());
|
||||
|
||||
let client = system_conf
|
||||
.get("UPDATE_CLIENT")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "public".to_string());
|
||||
|
||||
let base_url = system_conf
|
||||
.get("UPDATE_BASE_URL")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "https://update.subgraph.com".to_string());
|
||||
|
||||
let min_root_version = system_conf
|
||||
.get("MIN_ROOT_VERSION")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1);
|
||||
|
||||
let require_signatures = system_conf
|
||||
.get("REQUIRE_SIGNATURES")
|
||||
.map(|s| s == "true" || s == "1")
|
||||
.unwrap_or(true);
|
||||
|
||||
Ok(Config {
|
||||
base_url,
|
||||
client,
|
||||
channel,
|
||||
min_root_version,
|
||||
require_signatures,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let dir = Path::new(USER_CONF_PATH).parent().unwrap();
|
||||
fs::create_dir_all(dir)?;
|
||||
|
||||
let mut content = String::new();
|
||||
|
||||
// Read existing config to preserve other settings
|
||||
if let Ok(existing) = fs::read_to_string(USER_CONF_PATH) {
|
||||
for line in existing.lines() {
|
||||
if !line.starts_with("UPDATE_CHANNEL=") {
|
||||
content.push_str(line);
|
||||
content.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content.push_str(&format!("UPDATE_CHANNEL=\"{}\"\n", self.channel));
|
||||
|
||||
fs::write(USER_CONF_PATH, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn repository_url(&self) -> String {
|
||||
format!("{}/{}", self.base_url, self.client)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_conf_file(path: &Path) -> Result<HashMap<String, String>> {
|
||||
let content =
|
||||
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
|
||||
let mut conf = HashMap::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(pos) = line.find('=') {
|
||||
let key = line[..pos].trim().to_string();
|
||||
let value = line[pos + 1..].trim().trim_matches('"').to_string();
|
||||
conf.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(conf)
|
||||
}
|
||||
210
citadel-tool/src/fetch/keyring.rs
Normal file
210
citadel-tool/src/fetch/keyring.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const KEYRING_PATH: &str = "/storage/citadel-state/trusted-keys/keyring.json";
|
||||
const BUILTIN_KEYS_DIR: &str = "/usr/share/citadel/keys";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Keyring {
|
||||
pub version: u32,
|
||||
pub trusted_keys: HashMap<String, TrustedKey>,
|
||||
#[serde(default)]
|
||||
pub default_channels: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrustedKey {
|
||||
pub key_id: String,
|
||||
pub public_key: String,
|
||||
pub added_at: String,
|
||||
pub trust_level: TrustLevel,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TrustLevel {
|
||||
Default,
|
||||
UserApproved,
|
||||
Pending,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TrustStatus {
|
||||
TrustedDefault,
|
||||
TrustedUser,
|
||||
Pending,
|
||||
Unknown,
|
||||
KeyMismatch { expected: String, actual: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelKey {
|
||||
pub key_id: String,
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
impl Keyring {
|
||||
pub fn load() -> Result<Self> {
|
||||
let mut keyring = if Path::new(KEYRING_PATH).exists() {
|
||||
let content = fs::read_to_string(KEYRING_PATH).context("Failed to read keyring")?;
|
||||
serde_json::from_str(&content).context("Failed to parse keyring")?
|
||||
} else {
|
||||
Keyring {
|
||||
version: 1,
|
||||
trusted_keys: HashMap::new(),
|
||||
default_channels: Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
// Load built-in keys from rootfs
|
||||
keyring.load_builtin_keys()?;
|
||||
|
||||
Ok(keyring)
|
||||
}
|
||||
|
||||
fn load_builtin_keys(&mut self) -> Result<()> {
|
||||
let keys_dir = Path::new(BUILTIN_KEYS_DIR);
|
||||
if !keys_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(keys_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
// Look for channel public keys (not image keys)
|
||||
let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
|
||||
// Skip image signing keys (*_image.pub)
|
||||
if filename.ends_with("_image") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.extension().map_or(false, |e| e == "pub") {
|
||||
let channel = filename.to_string();
|
||||
let public_key = fs::read_to_string(&path)?.trim().to_string();
|
||||
let key_id = compute_key_id(&public_key);
|
||||
|
||||
// Built-in keys don't override user keys
|
||||
if !self.trusted_keys.contains_key(&channel) {
|
||||
self.trusted_keys.insert(
|
||||
channel.clone(),
|
||||
TrustedKey {
|
||||
key_id,
|
||||
public_key,
|
||||
added_at: "built-in".to_string(),
|
||||
trust_level: TrustLevel::Default,
|
||||
comment: Some("Shipped with OS".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
if !self.default_channels.contains(&channel) {
|
||||
self.default_channels.push(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let dir = Path::new(KEYRING_PATH).parent().unwrap();
|
||||
fs::create_dir_all(dir)?;
|
||||
|
||||
// Only save non-default keys (default keys come from rootfs)
|
||||
let saveable: HashMap<_, _> = self
|
||||
.trusted_keys
|
||||
.iter()
|
||||
.filter(|(_, k)| k.trust_level != TrustLevel::Default)
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
|
||||
let save_keyring = Keyring {
|
||||
version: self.version,
|
||||
trusted_keys: saveable,
|
||||
default_channels: Vec::new(), // Don't save default list
|
||||
};
|
||||
|
||||
let content = serde_json::to_string_pretty(&save_keyring)?;
|
||||
fs::write(KEYRING_PATH, content)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_trust(&self, channel: &str, key_id: &str) -> TrustStatus {
|
||||
match self.trusted_keys.get(channel) {
|
||||
Some(key) => {
|
||||
if key.key_id == key_id {
|
||||
match key.trust_level {
|
||||
TrustLevel::Default => TrustStatus::TrustedDefault,
|
||||
TrustLevel::UserApproved => TrustStatus::TrustedUser,
|
||||
TrustLevel::Pending => TrustStatus::Pending,
|
||||
}
|
||||
} else {
|
||||
TrustStatus::KeyMismatch {
|
||||
expected: key.key_id.clone(),
|
||||
actual: key_id.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
None => TrustStatus::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_key(&self, channel: &str) -> Option<&TrustedKey> {
|
||||
self.trusted_keys.get(channel)
|
||||
}
|
||||
|
||||
pub fn is_default(&self, channel: &str) -> bool {
|
||||
self.default_channels.contains(&channel.to_string())
|
||||
}
|
||||
|
||||
pub fn add_trusted(
|
||||
&mut self,
|
||||
channel: &str,
|
||||
key: &ChannelKey,
|
||||
level: TrustLevel,
|
||||
) -> Result<()> {
|
||||
self.trusted_keys.insert(
|
||||
channel.to_string(),
|
||||
TrustedKey {
|
||||
key_id: key.key_id.clone(),
|
||||
public_key: key.public_key.clone(),
|
||||
added_at: Utc::now().to_rfc3339(),
|
||||
trust_level: level,
|
||||
comment: None,
|
||||
},
|
||||
);
|
||||
self.save()
|
||||
}
|
||||
|
||||
pub fn add_pending(&mut self, channel: &str, key: &ChannelKey) -> Result<()> {
|
||||
self.add_trusted(channel, key, TrustLevel::Pending)
|
||||
}
|
||||
|
||||
pub fn approve(&mut self, channel: &str) -> Result<()> {
|
||||
if let Some(key) = self.trusted_keys.get_mut(channel) {
|
||||
key.trust_level = TrustLevel::UserApproved;
|
||||
key.added_at = Utc::now().to_rfc3339();
|
||||
key.comment = Some("Approved by user".to_string());
|
||||
}
|
||||
self.save()
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, channel: &str) -> Result<()> {
|
||||
self.trusted_keys.remove(channel);
|
||||
self.save()
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_key_id(public_key: &str) -> String {
|
||||
let hash = Sha256::digest(public_key.as_bytes());
|
||||
hex::encode(&hash[..8])
|
||||
}
|
||||
121
citadel-tool/src/fetch/metadata.rs
Normal file
121
citadel-tool/src/fetch/metadata.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SignedMetadata<T> {
|
||||
pub signatures: Vec<Signature>,
|
||||
pub signed: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Signature {
|
||||
pub keyid: String,
|
||||
pub sig: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RootMetadata {
|
||||
#[serde(rename = "_type")]
|
||||
pub metadata_type: String,
|
||||
pub spec_version: String,
|
||||
pub version: u32,
|
||||
pub expires: String,
|
||||
pub keys: HashMap<String, TufKey>,
|
||||
pub roles: HashMap<String, RoleDefinition>,
|
||||
pub consistent_snapshot: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TufKey {
|
||||
pub keytype: String,
|
||||
pub scheme: String,
|
||||
pub keyval: KeyValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeyValue {
|
||||
pub public: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoleDefinition {
|
||||
pub keyids: Vec<String>,
|
||||
pub threshold: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TimestampMetadata {
|
||||
#[serde(rename = "_type")]
|
||||
pub metadata_type: String,
|
||||
pub spec_version: String,
|
||||
pub version: u32,
|
||||
pub expires: String,
|
||||
pub meta: HashMap<String, MetaFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MetaFile {
|
||||
pub version: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub length: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hashes: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SnapshotMetadata {
|
||||
#[serde(rename = "_type")]
|
||||
pub metadata_type: String,
|
||||
pub spec_version: String,
|
||||
pub version: u32,
|
||||
pub expires: String,
|
||||
pub meta: HashMap<String, MetaFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TargetsMetadata {
|
||||
#[serde(rename = "_type")]
|
||||
pub metadata_type: String,
|
||||
pub spec_version: String,
|
||||
pub version: u32,
|
||||
pub expires: String,
|
||||
pub targets: HashMap<String, TargetInfo>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delegations: Option<Delegations>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TargetInfo {
|
||||
pub length: u64,
|
||||
pub hashes: HashMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub custom: Option<TargetCustom>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TargetCustom {
|
||||
pub version: String,
|
||||
pub image_type: String,
|
||||
pub channel: String,
|
||||
pub timestamp: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub min_version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub release_notes_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Delegations {
|
||||
pub keys: HashMap<String, TufKey>,
|
||||
pub roles: Vec<DelegatedRole>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DelegatedRole {
|
||||
pub name: String,
|
||||
pub keyids: Vec<String>,
|
||||
pub threshold: u32,
|
||||
pub paths: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub terminating: bool,
|
||||
}
|
||||
699
citadel-tool/src/fetch/mod.rs
Normal file
699
citadel-tool/src/fetch/mod.rs
Normal file
@@ -0,0 +1,699 @@
|
||||
mod client;
|
||||
mod config;
|
||||
mod keyring;
|
||||
mod metadata;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "citadel-fetch")]
|
||||
#[command(about = "Citadel update client")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Commands {
|
||||
/// Check for available updates
|
||||
Check,
|
||||
|
||||
/// Download and install updates
|
||||
Update {
|
||||
/// Don't prompt for confirmation
|
||||
#[arg(short, long)]
|
||||
yes: bool,
|
||||
|
||||
/// Only download specific component
|
||||
#[arg(short, long)]
|
||||
component: Option<String>,
|
||||
},
|
||||
|
||||
/// Show current update status
|
||||
Status,
|
||||
|
||||
/// Manage update channels
|
||||
Channel {
|
||||
#[command(subcommand)]
|
||||
command: ChannelCommands,
|
||||
},
|
||||
|
||||
/// Manage trusted signing keys
|
||||
Keyring {
|
||||
#[command(subcommand)]
|
||||
command: KeyringCommands,
|
||||
},
|
||||
|
||||
/// Refresh TUF metadata from server
|
||||
Refresh,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum ChannelCommands {
|
||||
/// Show current channel
|
||||
Show,
|
||||
|
||||
/// List available channels
|
||||
List,
|
||||
|
||||
/// Switch to a different channel
|
||||
Set {
|
||||
/// Channel name to switch to
|
||||
channel: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum KeyringCommands {
|
||||
/// List all trusted keys
|
||||
List,
|
||||
|
||||
/// Show details of a specific channel's key
|
||||
Show {
|
||||
/// Channel name
|
||||
channel: String,
|
||||
},
|
||||
|
||||
/// Add a key from the remote repository
|
||||
Add {
|
||||
/// Channel name
|
||||
channel: String,
|
||||
},
|
||||
|
||||
/// Remove a key from the keyring
|
||||
Remove {
|
||||
/// Channel name
|
||||
channel: String,
|
||||
},
|
||||
|
||||
/// Discover available channels from remote
|
||||
Discover,
|
||||
}
|
||||
|
||||
pub fn main() {
|
||||
env_logger::init();
|
||||
if let Err(e) = run() {
|
||||
eprintln!("Error: {:#}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
log::debug!("Executing command: {:?}", cli.command);
|
||||
|
||||
match cli.command {
|
||||
Commands::Check => cmd_check(),
|
||||
Commands::Update { yes, component } => cmd_update(yes, component),
|
||||
Commands::Status => cmd_status(),
|
||||
Commands::Channel { command } => match command {
|
||||
ChannelCommands::Show => cmd_channel_show(),
|
||||
ChannelCommands::List => cmd_channel_list(),
|
||||
ChannelCommands::Set { channel } => cmd_channel_set(&channel),
|
||||
},
|
||||
Commands::Keyring { command } => match command {
|
||||
KeyringCommands::List => cmd_keyring_list(),
|
||||
KeyringCommands::Show { channel } => cmd_keyring_show(&channel),
|
||||
KeyringCommands::Add { channel } => cmd_keyring_add(&channel),
|
||||
KeyringCommands::Remove { channel } => cmd_keyring_remove(&channel),
|
||||
KeyringCommands::Discover => cmd_keyring_discover(),
|
||||
},
|
||||
Commands::Refresh => cmd_refresh(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_check() -> Result<()> {
|
||||
log::debug!("Executing cmd_check");
|
||||
let config = config::Config::load()?;
|
||||
let mut client = client::TufClient::new(&config)?;
|
||||
|
||||
println!("Checking for updates...");
|
||||
println!(" Repository: {}", config.repository_url());
|
||||
println!(" Channel: {}", config.channel);
|
||||
|
||||
client.refresh_metadata()?;
|
||||
|
||||
let updates = client.check_for_updates(&config.channel)?;
|
||||
|
||||
if updates.is_empty() {
|
||||
println!("✓ System is up to date");
|
||||
} else {
|
||||
println!("Updates available:");
|
||||
for update in &updates {
|
||||
println!(
|
||||
" {} {} → {}",
|
||||
update.component, update.current_version, update.new_version
|
||||
);
|
||||
if let Some(size) = update.download_size {
|
||||
println!(" Download size: {:.1} MB", size as f64 / 1_048_576.0);
|
||||
}
|
||||
}
|
||||
println!("Run 'citadel-fetch update' to install updates");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_update(yes: bool, component: Option<String>) -> Result<()> {
|
||||
log::debug!(
|
||||
"Executing cmd_update with yes={}, component={:?}",
|
||||
yes,
|
||||
component
|
||||
);
|
||||
let config = config::Config::load()?;
|
||||
let mut keyring = keyring::Keyring::load()?;
|
||||
let mut client = client::TufClient::new(&config)?;
|
||||
|
||||
println!("Checking for updates...");
|
||||
client.refresh_metadata()?;
|
||||
|
||||
// Verify channel key trust
|
||||
let channel_key = client.get_channel_key(&config.channel)?;
|
||||
let trust_status = keyring.check_trust(&config.channel, &channel_key.key_id);
|
||||
println!(
|
||||
"Channel '{}' trust status: {:?}",
|
||||
config.channel, trust_status
|
||||
);
|
||||
|
||||
match &trust_status {
|
||||
keyring::TrustStatus::TrustedDefault => {
|
||||
println!(
|
||||
" Channel '{}' signed by: {} (default key)",
|
||||
config.channel,
|
||||
&channel_key.key_id[..16]
|
||||
);
|
||||
}
|
||||
keyring::TrustStatus::TrustedUser => {
|
||||
println!(
|
||||
" Channel '{}' signed by: {} (user approved)",
|
||||
config.channel,
|
||||
&channel_key.key_id[..16]
|
||||
);
|
||||
}
|
||||
keyring::TrustStatus::Unknown => {
|
||||
println!(
|
||||
"\n⚠ WARNING: Channel '{}' key not in trusted keyring",
|
||||
config.channel
|
||||
);
|
||||
println!(" Key ID: {}", channel_key.key_id);
|
||||
println!(" Public Key: {}...", &channel_key.public_key[..48]);
|
||||
println!("\n This key was verified by the TUF root certificate,");
|
||||
println!(" but you haven't explicitly trusted it yet.");
|
||||
if !yes && !prompt_yes_no("\nTrust this key and continue?")? {
|
||||
println!("Update cancelled by user");
|
||||
return Ok(());
|
||||
}
|
||||
keyring.add_trusted(
|
||||
&config.channel,
|
||||
&channel_key,
|
||||
keyring::TrustLevel::UserApproved,
|
||||
)?;
|
||||
println!(
|
||||
"✓ Channel key for '{}' added to trusted keyring",
|
||||
config.channel
|
||||
);
|
||||
}
|
||||
keyring::TrustStatus::KeyMismatch { expected, actual } => {
|
||||
println!("\n🚨 WARNING: Channel key has changed!");
|
||||
println!(" Expected: {}", expected);
|
||||
println!(" Received: {}", actual);
|
||||
if !yes {
|
||||
println!("\nThis could indicate a security issue. Contact channel maintainer.");
|
||||
if !prompt_yes_no("Accept new key anyway?")? {
|
||||
println!("Update cancelled by user due to key mismatch");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
keyring.add_trusted(
|
||||
&config.channel,
|
||||
&channel_key,
|
||||
keyring::TrustLevel::UserApproved,
|
||||
)?;
|
||||
println!(
|
||||
"✓ New channel key for '{}' accepted and added to trusted keyring",
|
||||
config.channel
|
||||
);
|
||||
}
|
||||
keyring::TrustStatus::Pending => {
|
||||
println!("\n⚠ Channel key pending approval");
|
||||
if !yes && !prompt_yes_no("Approve this key and continue?")? {
|
||||
println!("Update cancelled by user");
|
||||
return Ok(());
|
||||
}
|
||||
keyring.approve(&config.channel)?;
|
||||
println!("✓ Channel key for '{}' approved", config.channel);
|
||||
}
|
||||
}
|
||||
|
||||
let updates = client.check_for_updates(&config.channel)?;
|
||||
|
||||
// Filter by component if specified
|
||||
let updates: Vec<_> = if let Some(ref comp) = component {
|
||||
println!("Filtering updates for component: {}", comp);
|
||||
updates
|
||||
.into_iter()
|
||||
.filter(|u| &u.component == comp)
|
||||
.collect()
|
||||
} else {
|
||||
updates
|
||||
};
|
||||
|
||||
if updates.is_empty() {
|
||||
println!("\n✓ System is up to date");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("\nUpdates to install:");
|
||||
let mut total_size: u64 = 0;
|
||||
for update in &updates {
|
||||
println!(
|
||||
" {} {} → {}",
|
||||
update.component, update.current_version, update.new_version
|
||||
);
|
||||
if let Some(size) = update.download_size {
|
||||
total_size += size;
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"\nTotal download: {:.1} MB",
|
||||
total_size as f64 / 1_048_576.0
|
||||
);
|
||||
|
||||
if !yes && !prompt_yes_no("\nProceed with update?")? {
|
||||
println!("Update cancelled by user");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Initiating download and installation...");
|
||||
for update in &updates {
|
||||
println!("Downloading {}...", update.component);
|
||||
let path = client.download_target(&config.channel, &update.target_path)?;
|
||||
println!(" ✓ Downloaded to {}", path.display());
|
||||
|
||||
println!("Installing {}...", update.component);
|
||||
install_update(&update.component, &path)?;
|
||||
println!(" ✓ Installed");
|
||||
}
|
||||
|
||||
println!("\n═══════════════════════════════════════════════════════════");
|
||||
println!(" ✓ UPDATE COMPLETE");
|
||||
println!(" Reboot to apply changes");
|
||||
println!("═══════════════════════════════════════════════════════════");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_status() -> Result<()> {
|
||||
log::debug!("Executing cmd_status");
|
||||
let config = config::Config::load()?;
|
||||
let os_release_info = config::parse_conf_file(Path::new("/etc/os-release")).unwrap_or_default();
|
||||
|
||||
println!("Citadel Update Status");
|
||||
println!("═══════════════════════════════════════════════════════════");
|
||||
println!("System Information:");
|
||||
println!(
|
||||
" Distro: {} {}",
|
||||
os_release_info
|
||||
.get("NAME")
|
||||
.unwrap_or(&"Unknown".to_string()),
|
||||
os_release_info
|
||||
.get("VERSION_ID")
|
||||
.unwrap_or(&"Unknown".to_string())
|
||||
);
|
||||
println!(" Channel: {}", config.channel);
|
||||
println!("Update Server:");
|
||||
println!(" URL: {}", config.repository_url());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_channel_show() -> Result<()> {
|
||||
log::debug!("Executing cmd_channel_show");
|
||||
let config = config::Config::load()?;
|
||||
let keyring = keyring::Keyring::load()?;
|
||||
|
||||
println!("Current channel: {}", config.channel);
|
||||
|
||||
if let Some(key) = keyring.get_key(&config.channel) {
|
||||
println!(" Key ID: {}", key.key_id);
|
||||
println!(" Trust: {:?}", key.trust_level);
|
||||
} else {
|
||||
println!(" No key found for current channel in keyring.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_channel_list() -> Result<()> {
|
||||
log::debug!("Executing cmd_channel_list");
|
||||
let config = config::Config::load()?;
|
||||
let mut client = client::TufClient::new(&config)?;
|
||||
let keyring = keyring::Keyring::load()?;
|
||||
|
||||
client.refresh_metadata()?;
|
||||
let channels = client.list_channels()?;
|
||||
|
||||
println!("Available channels:");
|
||||
println!("───────────────────────────────────────────────────────────");
|
||||
|
||||
if channels.is_empty() {
|
||||
println!(" No channels found.");
|
||||
} else {
|
||||
for channel in channels {
|
||||
let current = if channel.name == config.channel {
|
||||
" (current)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let trust = match keyring.get_key(&channel.name) {
|
||||
Some(k) => match k.trust_level {
|
||||
keyring::TrustLevel::Default => "✓ default",
|
||||
keyring::TrustLevel::UserApproved => "✓ trusted",
|
||||
keyring::TrustLevel::Pending => "○ pending",
|
||||
},
|
||||
None => "○ unknown",
|
||||
};
|
||||
|
||||
println!(
|
||||
" {:<12} {} [{}]{}",
|
||||
channel.name,
|
||||
&channel.key_id[..16],
|
||||
trust,
|
||||
current
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_channel_set(channel: &str) -> Result<()> {
|
||||
log::debug!("Executing cmd_channel_set for channel '{}'", channel);
|
||||
let mut config = config::Config::load()?;
|
||||
let mut keyring = keyring::Keyring::load()?;
|
||||
let mut client = client::TufClient::new(&config)?;
|
||||
|
||||
println!("Switching to channel '{}'...", channel);
|
||||
|
||||
// Refresh metadata to get channel info
|
||||
client.refresh_metadata()?;
|
||||
|
||||
// Get the channel's signing key
|
||||
let channel_key = client
|
||||
.get_channel_key(channel)
|
||||
.with_context(|| format!("Channel '{}' not found", channel))?;
|
||||
|
||||
let trust_status = keyring.check_trust(channel, &channel_key.key_id);
|
||||
println!("Channel '{}' trust status: {:?}", channel, trust_status);
|
||||
|
||||
match trust_status {
|
||||
keyring::TrustStatus::TrustedDefault => {
|
||||
println!(
|
||||
"\n Key: {} (default, shipped with OS)",
|
||||
&channel_key.key_id[..16]
|
||||
);
|
||||
}
|
||||
keyring::TrustStatus::TrustedUser => {
|
||||
println!(
|
||||
"\n Key: {} (previously approved)",
|
||||
&channel_key.key_id[..16]
|
||||
);
|
||||
}
|
||||
keyring::TrustStatus::Pending => {
|
||||
println!("\n════════════════════════════════════════════════════════");
|
||||
println!(" PENDING KEY APPROVAL");
|
||||
println!("════════════════════════════════════════════════════════");
|
||||
println!("\n Channel: {}", channel);
|
||||
println!(" Key ID: {}", channel_key.key_id);
|
||||
println!("\n This key was fetched but not yet approved.\n");
|
||||
|
||||
if !prompt_yes_no("Approve this key?")? {
|
||||
println!("Channel switch cancelled by user");
|
||||
return Ok(());
|
||||
}
|
||||
keyring.approve(channel)?;
|
||||
println!("Channel key for '{}' approved", channel);
|
||||
}
|
||||
keyring::TrustStatus::Unknown => {
|
||||
println!("\n════════════════════════════════════════════════════════");
|
||||
println!(" NEW CHANNEL KEY");
|
||||
println!("════════════════════════════════════════════════════════");
|
||||
println!("\n Channel: {}", channel);
|
||||
println!(" Key ID: {}", channel_key.key_id);
|
||||
println!(" Key: {}...", &channel_key.public_key[..48]);
|
||||
println!("\n ⚠ This key is not in your trusted keyring.");
|
||||
println!(" Only trust keys from verified sources.\n");
|
||||
|
||||
if !prompt_yes_no("Trust this key?")? {
|
||||
println!("Channel switch cancelled by user");
|
||||
return Ok(());
|
||||
}
|
||||
keyring.add_trusted(channel, &channel_key, keyring::TrustLevel::UserApproved)?;
|
||||
println!(
|
||||
"New channel key for '{}' trusted and added to keyring",
|
||||
channel
|
||||
);
|
||||
}
|
||||
keyring::TrustStatus::KeyMismatch { expected, actual } => {
|
||||
println!("\n════════════════════════════════════════════════════════");
|
||||
println!(" 🚨 KEY MISMATCH WARNING");
|
||||
println!("════════════════════════════════════════════════════════");
|
||||
println!("\n Channel: {}", channel);
|
||||
println!(" Expected: {}", expected);
|
||||
println!(" Received: {}", actual);
|
||||
println!("\n The signing key for this channel has changed.");
|
||||
println!(" This could indicate:");
|
||||
println!(" - Legitimate key rotation");
|
||||
println!(" - A potential security issue");
|
||||
println!("\n Verify with channel maintainer before accepting.\n");
|
||||
|
||||
if !prompt_yes_no("Accept new key?")? {
|
||||
println!("Channel switch cancelled by user due to key mismatch");
|
||||
return Ok(());
|
||||
}
|
||||
keyring.add_trusted(channel, &channel_key, keyring::TrustLevel::UserApproved)?;
|
||||
println!(
|
||||
"New key for channel '{}' accepted despite mismatch",
|
||||
channel
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Save new channel to config
|
||||
config.channel = channel.to_string();
|
||||
config.save()?;
|
||||
|
||||
println!("\n✓ Channel switched to '{}'", channel);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_keyring_list() -> Result<()> {
|
||||
println!("Executing cmd_keyring_list");
|
||||
let keyring = keyring::Keyring::load()?;
|
||||
|
||||
println!("Trusted Channel Keys");
|
||||
println!("═══════════════════════════════════════════════════════════");
|
||||
|
||||
if keyring.trusted_keys.is_empty() {
|
||||
println!(" No keys in keyring");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for (channel, key) in &keyring.trusted_keys {
|
||||
let trust = match key.trust_level {
|
||||
keyring::TrustLevel::Default => "[default]",
|
||||
keyring::TrustLevel::UserApproved => "[user] ",
|
||||
keyring::TrustLevel::Pending => "[pending]",
|
||||
};
|
||||
let comment = key.comment.as_deref().unwrap_or("");
|
||||
println!(
|
||||
" {:<12} {} {} {}",
|
||||
channel,
|
||||
&key.key_id[..16],
|
||||
trust,
|
||||
comment
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_keyring_show(channel: &str) -> Result<()> {
|
||||
log::debug!("Executing cmd_keyring_show for channel '{}'", channel);
|
||||
let keyring = keyring::Keyring::load()?;
|
||||
|
||||
match keyring.get_key(channel) {
|
||||
Some(key) => {
|
||||
println!("Channel: {}", channel);
|
||||
println!("═══════════════════════════════════════════════════════════");
|
||||
println!(" Key ID: {}", key.key_id);
|
||||
println!(" Public Key: {}", key.public_key);
|
||||
println!(" Trust: {:?}", key.trust_level);
|
||||
println!(" Added: {}", key.added_at);
|
||||
if let Some(comment) = &key.comment {
|
||||
println!(" Comment: {}", comment);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!("No key found for channel '{}'", channel);
|
||||
println!("Use 'citadel-fetch keyring add {}' to add it", channel);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_keyring_add(channel: &str) -> Result<()> {
|
||||
log::debug!("Executing cmd_keyring_add for channel '{}'", channel);
|
||||
let config = config::Config::load()?;
|
||||
let mut keyring = keyring::Keyring::load()?;
|
||||
let mut client = client::TufClient::new(&config)?;
|
||||
|
||||
println!("Fetching key for channel '{}'...", channel);
|
||||
|
||||
client.refresh_metadata()?;
|
||||
|
||||
let channel_key = client
|
||||
.get_channel_key(channel)
|
||||
.with_context(|| format!("Channel '{}' not found", channel))?;
|
||||
|
||||
println!("\n Channel: {}", channel);
|
||||
println!(" Key ID: {}", channel_key.key_id);
|
||||
println!(" Key: {}...", &channel_key.public_key[..48]);
|
||||
|
||||
if keyring.get_key(channel).is_some() {
|
||||
println!("\n ⚠ A key for this channel already exists.");
|
||||
if !prompt_yes_no("Replace existing key?")? {
|
||||
println!("Key add cancelled by user");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if !prompt_yes_no("\nTrust this key?")? {
|
||||
println!("Key add cancelled by user");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
keyring.add_trusted(channel, &channel_key, keyring::TrustLevel::UserApproved)?;
|
||||
println!("\n✓ Key added to keyring");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_keyring_remove(channel: &str) -> Result<()> {
|
||||
log::debug!("Executing cmd_keyring_remove for channel '{}'", channel);
|
||||
let mut keyring = keyring::Keyring::load()?;
|
||||
|
||||
if keyring.get_key(channel).is_none() {
|
||||
println!("No key found for channel '{}'", channel);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if keyring.is_default(channel) {
|
||||
println!("⚠ '{}' is a default key shipped with the OS.", channel);
|
||||
println!(" Removing it may prevent updates from this channel.");
|
||||
}
|
||||
|
||||
if !prompt_yes_no(&format!("Remove key for '{}'?", channel))? {
|
||||
println!("Key removal cancelled by user");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
keyring.remove(channel)?;
|
||||
println!("✓ Key removed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_keyring_discover() -> Result<()> {
|
||||
log::debug!("Executing cmd_keyring_discover");
|
||||
let config = config::Config::load()?;
|
||||
let mut client = client::TufClient::new(&config)?;
|
||||
let keyring = keyring::Keyring::load()?;
|
||||
|
||||
println!("Discovering channels from {}...\n", config.base_url);
|
||||
|
||||
client.refresh_metadata()?;
|
||||
let channels = client.list_channels()?;
|
||||
|
||||
println!("Available Channels");
|
||||
println!("═══════════════════════════════════════════════════════════");
|
||||
|
||||
if channels.is_empty() {
|
||||
println!(" No channels found in repository.");
|
||||
} else {
|
||||
for channel in channels {
|
||||
let status = match keyring.get_key(&channel.name) {
|
||||
Some(k) => match k.trust_level {
|
||||
keyring::TrustLevel::Default => "✓ trusted (default)",
|
||||
keyring::TrustLevel::UserApproved => "✓ trusted (user)",
|
||||
keyring::TrustLevel::Pending => "○ pending approval",
|
||||
},
|
||||
None => "○ not in keyring",
|
||||
};
|
||||
|
||||
println!(
|
||||
" {:<12} {} {}",
|
||||
channel.name,
|
||||
&channel.key_id[..16],
|
||||
status
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nAdd a key: citadel-fetch keyring add <channel>");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_refresh() -> Result<()> {
|
||||
log::debug!("Executing cmd_refresh");
|
||||
let config = config::Config::load()?;
|
||||
let mut client = client::TufClient::new(&config)?;
|
||||
|
||||
println!("Refreshing TUF metadata...");
|
||||
client.refresh_metadata()?;
|
||||
println!("✓ Metadata refreshed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_yes_no(prompt: &str) -> Result<bool> {
|
||||
use std::io::{self, Write};
|
||||
|
||||
print!("{} (y/N): ", prompt);
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
|
||||
let answer = input.trim().to_lowercase();
|
||||
Ok(answer == "y" || answer == "yes")
|
||||
}
|
||||
|
||||
fn install_update(component: &str, image_path: &std::path::Path) -> Result<()> {
|
||||
// This integrates with libcitadel's image installation
|
||||
// For now, just copy to the appropriate location
|
||||
use std::process::Command;
|
||||
|
||||
let dest = match component {
|
||||
"rootfs" => "/run/citadel/images/citadel-rootfs.img",
|
||||
"kernel" => "/run/citadel/images/citadel-kernel.img",
|
||||
"extra" => "/run/citadel/images/citadel-extra.img",
|
||||
_ => return Err(anyhow::anyhow!("Unknown component: {}", component)),
|
||||
};
|
||||
|
||||
// Use citadel-image to install
|
||||
let status = Command::new("/usr/bin/citadel-image")
|
||||
.args(["install", &image_path.to_string_lossy(), dest])
|
||||
.status()?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("Failed to install {} image", component);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
use clap::{command, ArgAction, Command};
|
||||
use clap::{Arg, ArgMatches};
|
||||
use hex;
|
||||
use std::path::Path;
|
||||
use std::process::exit;
|
||||
use clap::{Arg,ArgMatches};
|
||||
use clap::{command, ArgAction, Command};
|
||||
use hex;
|
||||
|
||||
use libcitadel::{Result, ResourceImage, Logger, LogLevel, Partition, KeyPair, ImageHeader, util};
|
||||
use libcitadel::public_key_for_channel;
|
||||
use libcitadel::{util, ImageHeader, KeyPair, Partition, ResourceImage, Result};
|
||||
|
||||
pub fn main() {
|
||||
let matches = command!()
|
||||
@@ -103,7 +104,7 @@ pub fn main() {
|
||||
|
||||
fn info(arg_matches: &ArgMatches) -> Result<()> {
|
||||
let img = load_image(arg_matches)?;
|
||||
print!("{}",String::from_utf8(img.header().metainfo_bytes())?);
|
||||
print!("{}", String::from_utf8(img.header().metainfo_bytes())?);
|
||||
info_signature(&img)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -113,22 +114,22 @@ fn info_signature(img: &ResourceImage) -> Result<()> {
|
||||
println!("Signature: {}", hex::encode(&img.header().signature()));
|
||||
} else {
|
||||
println!("Signature: No Signature");
|
||||
return Ok(());
|
||||
}
|
||||
match img.header().public_key()? {
|
||||
Some(pubkey) => {
|
||||
if img.header().verify_signature(pubkey) {
|
||||
println!("Signature is valid");
|
||||
} else {
|
||||
println!("Signature verify FAILED");
|
||||
}
|
||||
},
|
||||
None => { println!("No public key found for channel '{}'", img.metainfo().channel()) },
|
||||
|
||||
let pubkey = public_key_for_channel(img.metainfo().channel())?;
|
||||
|
||||
if img.header().verify_signature(pubkey) {
|
||||
println!("Signature is valid");
|
||||
} else {
|
||||
println!("Signature verify FAILED");
|
||||
}
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn metainfo(arg_matches: &ArgMatches) -> Result<()> {
|
||||
let img = load_image(arg_matches)?;
|
||||
print!("{}",String::from_utf8(img.header().metainfo_bytes())?);
|
||||
print!("{}", String::from_utf8(img.header().metainfo_bytes())?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -167,7 +168,8 @@ fn verify_shasum(arg_matches: &ArgMatches) -> Result<()> {
|
||||
}
|
||||
|
||||
fn load_image(arg_matches: &ArgMatches) -> Result<ResourceImage> {
|
||||
let path = arg_matches.get_one::<String>("path")
|
||||
let path = arg_matches
|
||||
.get_one::<String>("path")
|
||||
.expect("path argument missing");
|
||||
|
||||
if !Path::new(path).exists() {
|
||||
@@ -183,7 +185,7 @@ fn load_image(arg_matches: &ArgMatches) -> Result<ResourceImage> {
|
||||
fn install_rootfs(arg_matches: &ArgMatches) -> Result<()> {
|
||||
if arg_matches.get_flag("choose") {
|
||||
let _ = choose_install_partition(true)?;
|
||||
return Ok(())
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let img = load_image(arg_matches)?;
|
||||
@@ -222,14 +224,18 @@ fn sign_image(arg_matches: &ArgMatches) -> Result<()> {
|
||||
}
|
||||
|
||||
fn install_image(arg_matches: &ArgMatches) -> Result<()> {
|
||||
let source = arg_matches.get_one::<String>("path")
|
||||
let source = arg_matches
|
||||
.get_one::<String>("path")
|
||||
.expect("path argument missing");
|
||||
|
||||
let img = load_image(arg_matches)?;
|
||||
let _hdr = img.header();
|
||||
let metainfo = img.metainfo();
|
||||
|
||||
// XXX verify signature?
|
||||
// Use existing function from libcitadel
|
||||
let pubkey = public_key_for_channel(metainfo.channel())?;
|
||||
if !img.header().verify_signature(pubkey) {
|
||||
bail!("Image signature verification failed");
|
||||
}
|
||||
|
||||
if !(metainfo.image_type() == "kernel" || metainfo.image_type() == "extra") {
|
||||
bail!("Cannot install image type {}", metainfo.image_type());
|
||||
@@ -250,13 +256,20 @@ fn install_image(arg_matches: &ArgMatches) -> Result<()> {
|
||||
if kernel_version.chars().any(|c| c == '/') {
|
||||
bail!("Kernel version field has / char");
|
||||
}
|
||||
format!("citadel-kernel-{}-{:03}.img", kernel_version, metainfo.version())
|
||||
format!(
|
||||
"citadel-kernel-{}-{}.img",
|
||||
kernel_version,
|
||||
metainfo.version()
|
||||
)
|
||||
} else {
|
||||
format!("citadel-extra-{:03}.img", metainfo.version())
|
||||
format!("citadel-extra-{}.img", metainfo.version())
|
||||
};
|
||||
|
||||
if !metainfo.channel().chars().all(|c| c.is_ascii_lowercase()) {
|
||||
bail!("Refusing to build path from strange channel name {}", metainfo.channel());
|
||||
bail!(
|
||||
"Refusing to build path from strange channel name {}",
|
||||
metainfo.channel()
|
||||
);
|
||||
}
|
||||
let image_dir = Path::new("/storage/resources").join(metainfo.channel());
|
||||
let image_dest = image_dir.join(filename);
|
||||
|
||||
@@ -12,6 +12,7 @@ use libcitadel::RealmFS;
|
||||
use libcitadel::Result;
|
||||
use libcitadel::OsRelease;
|
||||
use libcitadel::KeyRing;
|
||||
use libcitadel::ResourceImage;
|
||||
use libcitadel::terminal::Base16Scheme;
|
||||
use libcitadel::UtsName;
|
||||
|
||||
@@ -479,11 +480,29 @@ impl Installer {
|
||||
}
|
||||
|
||||
fn setup_storage_resources(&self) -> Result<()> {
|
||||
let channel = match OsRelease::citadel_channel() {
|
||||
Some(channel) => channel,
|
||||
None => "dev",
|
||||
// Get the channel from the extra image metadata rather than OsRelease
|
||||
// because during installation in live mode, OsRelease reads from initramfs
|
||||
// which doesn't have CITADEL_CHANNEL set, but the images have it in metadata
|
||||
let extra_path = self.artifact_path(EXTRA_IMAGE_NAME);
|
||||
let channel = if extra_path.exists() {
|
||||
match ResourceImage::from_path(&extra_path) {
|
||||
Ok(img) => img.metainfo().channel().to_string(),
|
||||
Err(_) => {
|
||||
// Fallback to OsRelease if we can't read the image
|
||||
OsRelease::citadel_channel()
|
||||
.ok_or_else(|| {
|
||||
format_err!("Failed to determine channel from extra image or OsRelease")
|
||||
})?
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
OsRelease::citadel_channel()
|
||||
.ok_or_else(|| format_err!("Failed to determine channel from OsRelease"))?
|
||||
.to_string()
|
||||
};
|
||||
let resources = self.storage().join("resources").join(channel);
|
||||
|
||||
let resources = self.storage().join("resources").join(&channel);
|
||||
util::create_dir(&resources)?;
|
||||
|
||||
self.sparse_copy_artifact(EXTRA_IMAGE_NAME, &resources)?;
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
#[macro_use] extern crate libcitadel;
|
||||
#[macro_use] extern crate serde_derive;
|
||||
#[macro_use] extern crate lazy_static;
|
||||
#[macro_use]
|
||||
extern crate libcitadel;
|
||||
#[macro_use]
|
||||
extern crate serde_derive;
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
|
||||
use libcitadel::RealmManager;
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::ffi::OsStr;
|
||||
use std::iter;
|
||||
use libcitadel::RealmManager;
|
||||
use std::path::Path;
|
||||
|
||||
mod boot;
|
||||
mod fetch;
|
||||
mod image;
|
||||
mod install;
|
||||
mod install_backend;
|
||||
@@ -22,7 +26,7 @@ fn main() {
|
||||
Ok(path) => path,
|
||||
Err(_e) => {
|
||||
return;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
let args = env::args().collect::<Vec<String>>();
|
||||
@@ -39,6 +43,8 @@ fn main() {
|
||||
realmfs::main(args);
|
||||
} else if exe == Path::new("/usr/bin/citadel-update") {
|
||||
update::main(args);
|
||||
} else if exe == Path::new("/usr/bin/citadel-fetch") {
|
||||
fetch::main();
|
||||
} else if exe == Path::new("/usr/libexec/citadel-desktop-sync") {
|
||||
sync::main(args);
|
||||
} else if exe == Path::new("/usr/libexec/citadel-run") {
|
||||
@@ -60,6 +66,7 @@ fn dispatch_command(args: Vec<String>) {
|
||||
"image" => image::main(),
|
||||
"realmfs" => realmfs::main(rebuild_args("citadel-realmfs", args)),
|
||||
"update" => update::main(rebuild_args("citadel-update", args)),
|
||||
"fetch" => fetch::main(),
|
||||
"mkimage" => mkimage::main(rebuild_args("citadel-mkimage", args)),
|
||||
"sync" => sync::main(rebuild_args("citadel-desktop-sync", args)),
|
||||
"run" => do_citadel_run(rebuild_args("citadel-run", args)),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::path::PathBuf;
|
||||
use std::fs::OpenOptions;
|
||||
use std::fs::{self,File};
|
||||
use std::io::{self,Write};
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use libcitadel::{Result, ImageHeader, devkeys, util};
|
||||
use libcitadel::{devkeys, keypair_for_channel_signing, util, ImageHeader, Result};
|
||||
|
||||
use super::config::BuildConfig;
|
||||
use std::path::Path;
|
||||
use libcitadel::verity::Verity;
|
||||
use std::path::Path;
|
||||
|
||||
pub struct UpdateBuilder {
|
||||
config: BuildConfig,
|
||||
@@ -19,15 +19,12 @@ pub struct UpdateBuilder {
|
||||
verity_root: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
const BLOCK_SIZE: usize = 4096;
|
||||
fn align(sz: usize, n: usize) -> usize {
|
||||
(sz + (n - 1)) & !(n - 1)
|
||||
}
|
||||
|
||||
|
||||
impl UpdateBuilder {
|
||||
|
||||
pub fn new(config: BuildConfig) -> UpdateBuilder {
|
||||
let image_data = config.workdir_path(UpdateBuilder::build_filename(&config));
|
||||
UpdateBuilder {
|
||||
@@ -38,15 +35,29 @@ impl UpdateBuilder {
|
||||
}
|
||||
|
||||
fn target_filename(&self) -> String {
|
||||
format!("citadel-{}-{}-{:03}.img", self.config.img_name(), self.config.channel(), self.config.version())
|
||||
format!(
|
||||
"citadel-{}-{}-{}.img",
|
||||
self.config.img_name(),
|
||||
self.config.channel(),
|
||||
self.config.version()
|
||||
)
|
||||
}
|
||||
|
||||
fn build_filename(config: &BuildConfig) -> String {
|
||||
format!("citadel-{}-{}-{:03}", config.image_type(), config.channel(), config.version())
|
||||
format!(
|
||||
"citadel-{}-{}-{}",
|
||||
config.image_type(),
|
||||
config.channel(),
|
||||
config.version()
|
||||
)
|
||||
}
|
||||
|
||||
fn verity_filename(&self) -> String {
|
||||
format!("verity-hash-{}-{:03}", self.config.image_type(), self.config.version())
|
||||
format!(
|
||||
"verity-hash-{}-{}",
|
||||
self.config.image_type(),
|
||||
self.config.version()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build(&mut self) -> Result<()> {
|
||||
@@ -154,7 +165,7 @@ impl UpdateBuilder {
|
||||
bail!("failed to compress {:?}: {}", self.image(), err);
|
||||
}
|
||||
// Rename back to original image_data filename
|
||||
util::rename(self.image().with_extension("xz"), self.image())?;
|
||||
util::rename(util::append_to_path(self.image(), ".xz"), self.image())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -192,6 +203,19 @@ impl UpdateBuilder {
|
||||
if self.config.channel() == "dev" {
|
||||
let sig = devkeys().sign(&metainfo);
|
||||
hdr.set_signature(sig.to_bytes());
|
||||
} else {
|
||||
let private_key_path_str = match self.config.private_key_path() {
|
||||
Some(path) => path,
|
||||
None => bail!("private-key-path not found in config for non-dev channel"),
|
||||
};
|
||||
let private_key_path = Path::new(private_key_path_str);
|
||||
let sig = keypair_for_channel_signing(private_key_path).sign(&metainfo);
|
||||
info!("Generated signature: {}", hex::encode(sig.to_bytes()));
|
||||
let generated_signature_bytes = sig.to_bytes();
|
||||
if generated_signature_bytes.iter().all(|&b| b == 0) {
|
||||
bail!("Generated signature is all zeros. Signing failed!");
|
||||
}
|
||||
hdr.set_signature(&generated_signature_bytes);
|
||||
}
|
||||
Ok(hdr)
|
||||
}
|
||||
@@ -217,12 +241,33 @@ impl UpdateBuilder {
|
||||
writeln!(v, "realmfs-name = \"{}\"", name)?;
|
||||
}
|
||||
writeln!(v, "channel = \"{}\"", self.config.channel())?;
|
||||
writeln!(v, "version = {}", self.config.version())?;
|
||||
writeln!(v, "version = \"{}\"", self.config.version())?;
|
||||
writeln!(v, "timestamp = \"{}\"", self.config.timestamp())?;
|
||||
writeln!(v, "nblocks = {}", self.nblocks.unwrap())?;
|
||||
writeln!(v, "shasum = \"{}\"", self.shasum.as_ref().unwrap())?;
|
||||
writeln!(v, "verity-salt = \"{}\"", self.verity_salt.as_ref().unwrap())?;
|
||||
writeln!(v, "verity-root = \"{}\"", self.verity_root.as_ref().unwrap())?;
|
||||
writeln!(
|
||||
v,
|
||||
"verity-salt = \"{}\"",
|
||||
self.verity_salt.as_ref().unwrap()
|
||||
)?;
|
||||
writeln!(
|
||||
v,
|
||||
"verity-root = \"{}\"",
|
||||
self.verity_root.as_ref().unwrap()
|
||||
)?;
|
||||
|
||||
if let Some(path) = self.config.public_key_path() {
|
||||
if let Ok(key) = fs::read_to_string(path) {
|
||||
writeln!(v, "public-key = \"{}\"", key.trim())?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = self.config.certificate_path() {
|
||||
if let Ok(cert) = fs::read_to_string(path) {
|
||||
writeln!(v, "authorizing-signature = \"{}\"", cert.trim())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use toml;
|
||||
|
||||
use libcitadel::{Result, util};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -9,7 +7,7 @@ pub struct BuildConfig {
|
||||
#[serde(rename = "image-type")]
|
||||
image_type: String,
|
||||
channel: String,
|
||||
version: usize,
|
||||
version: String,
|
||||
timestamp: String,
|
||||
source: String,
|
||||
#[serde(default)]
|
||||
@@ -22,6 +20,15 @@ pub struct BuildConfig {
|
||||
#[serde(rename = "realmfs-name")]
|
||||
realmfs_name: Option<String>,
|
||||
|
||||
#[serde(rename = "private-key-path")]
|
||||
private_key_path: Option<String>,
|
||||
|
||||
#[serde(rename = "public-key-path")]
|
||||
public_key_path: Option<String>,
|
||||
|
||||
#[serde(rename = "certificate-path")]
|
||||
certificate_path: Option<String>,
|
||||
|
||||
#[serde(skip)]
|
||||
basedir: PathBuf,
|
||||
#[serde(skip)]
|
||||
@@ -46,6 +53,43 @@ impl BuildConfig {
|
||||
Some(ref version) => format!("{}-{}", &config.image_type, version),
|
||||
None => config.image_type.to_owned(),
|
||||
};
|
||||
|
||||
// Auto-detect private key path if not specified
|
||||
if config.private_key_path.is_none() && config.channel != "dev" {
|
||||
let default_key_path = format!("/usr/share/citadel/keys/{}_image.key", config.channel);
|
||||
info!("No private-key-path specified, using default: {}", default_key_path);
|
||||
config.private_key_path = Some(default_key_path);
|
||||
}
|
||||
|
||||
// Auto-detect public key and certificate paths if not specified
|
||||
if config.public_key_path.is_none() && config.channel != "dev" {
|
||||
// First check relative to config file (for build machine)
|
||||
let local_path = config.basedir.join(format!("{}_image.pub", config.channel));
|
||||
if local_path.exists() {
|
||||
config.public_key_path = Some(local_path.to_string_lossy().into_owned());
|
||||
} else {
|
||||
// Fallback to runtime path
|
||||
let path = format!("/usr/share/citadel/keys/{}_image.pub", config.channel);
|
||||
if Path::new(&path).exists() {
|
||||
config.public_key_path = Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.certificate_path.is_none() && config.channel != "dev" {
|
||||
// First check relative to config file (for build machine)
|
||||
let local_path = config.basedir.join(format!("{}_image.cert", config.channel));
|
||||
if local_path.exists() {
|
||||
config.certificate_path = Some(local_path.to_string_lossy().into_owned());
|
||||
} else {
|
||||
// Fallback to runtime path
|
||||
let path = format!("/usr/share/citadel/keys/{}_image.cert", config.channel);
|
||||
if Path::new(&path).exists() {
|
||||
config.certificate_path = Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -102,8 +146,8 @@ impl BuildConfig {
|
||||
self.realmfs_name.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
pub fn version(&self) -> usize {
|
||||
self.version
|
||||
pub fn version(&self) -> &str {
|
||||
&self.version
|
||||
}
|
||||
|
||||
pub fn channel(&self) -> &str {
|
||||
@@ -117,4 +161,16 @@ impl BuildConfig {
|
||||
pub fn compress(&self) -> bool {
|
||||
self.compress
|
||||
}
|
||||
|
||||
pub fn private_key_path(&self) -> Option<&str> {
|
||||
self.private_key_path.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
pub fn public_key_path(&self) -> Option<&str> {
|
||||
self.public_key_path.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
pub fn certificate_path(&self) -> Option<&str> {
|
||||
self.certificate_path.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ fn create_tmp_copy(path: &Path) -> Result<PathBuf> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn install_image(path: &Path, flags: u32) -> Result<()> {
|
||||
pub fn install_image(path: &Path, flags: u32) -> Result<()> {
|
||||
if !path.exists() || path.file_name().is_none() {
|
||||
bail!("file path {} does not exist", path.display());
|
||||
}
|
||||
@@ -140,7 +140,7 @@ fn prepare_image(image: &ResourceImage, flags: u32) -> Result<()> {
|
||||
}
|
||||
|
||||
fn install_extra_image(image: &ResourceImage) -> Result<()> {
|
||||
let filename = format!("citadel-extra-{:03}.img", image.header().metainfo().version());
|
||||
let filename = format!("citadel-extra-{}.img", image.header().metainfo().version());
|
||||
install_image_file(image, filename.as_str())?;
|
||||
remove_old_extra_images(image)?;
|
||||
Ok(())
|
||||
@@ -186,7 +186,7 @@ fn install_kernel_image(image: &mut ResourceImage) -> Result<()> {
|
||||
info!("kernel version is {}", kernel_version);
|
||||
install_kernel_file(image, &kernel_version)?;
|
||||
|
||||
let filename = format!("citadel-kernel-{}-{:03}.img", kernel_version, version);
|
||||
let filename = format!("citadel-kernel-{}-{}.img", kernel_version, version);
|
||||
install_image_file(image, &filename)?;
|
||||
|
||||
let all_versions = all_boot_kernel_versions()?;
|
||||
|
||||
@@ -20,6 +20,12 @@ walkdir = "2"
|
||||
dbus = "0.6"
|
||||
posix-acl = "1.0.0"
|
||||
procfs = "0.12.0"
|
||||
anyhow = "1.0"
|
||||
clap = "4.5"
|
||||
tempfile = "3.21"
|
||||
semver = "1.0"
|
||||
sha2 = "0.10"
|
||||
ed25519-dalek = { version = "2.1", features = ["pkcs8"] }
|
||||
|
||||
[dependencies.inotify]
|
||||
version = "0.8"
|
||||
|
||||
@@ -382,8 +382,33 @@ impl ImageHeader {
|
||||
self.set_signature(&zeros);
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> Result<Option<PublicKey>> {
|
||||
public_key_for_channel(self.metainfo().channel())
|
||||
pub fn public_key(&self) -> Result<PublicKey> {
|
||||
let metainfo = self.metainfo();
|
||||
|
||||
// 1. Try Hierarchical Verification if fields are present
|
||||
if let (Some(pk_hex), Some(sig_hex)) = (metainfo.public_key(), metainfo.authorizing_signature()) {
|
||||
let root_pubkey = match crate::root_image_public_key() {
|
||||
Ok(rk) => rk,
|
||||
Err(e) => {
|
||||
warn!("Could not load Root Image Key for hierarchical verification: {}", e);
|
||||
return public_key_for_channel(metainfo.channel());
|
||||
}
|
||||
};
|
||||
|
||||
let channel_pubkey = PublicKey::from_hex(pk_hex)?;
|
||||
let sig_bytes = hex::decode(sig_hex).map_err(context!("Invalid authorizing-signature hex"))?;
|
||||
let pk_bytes = hex::decode(pk_hex).map_err(context!("Invalid public-key hex"))?;
|
||||
|
||||
if root_pubkey.verify(&pk_bytes, &sig_bytes) {
|
||||
info!("Channel key authorization SUCCESS...");
|
||||
return Ok(channel_pubkey);
|
||||
} else {
|
||||
bail!("Security Critical: Channel key signature verification FAILED. Aborting.");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to standard channel-based lookup (key must be on disk)
|
||||
public_key_for_channel(metainfo.channel())
|
||||
}
|
||||
|
||||
pub fn verify_signature(&self, pubkey: PublicKey) -> bool {
|
||||
@@ -453,7 +478,7 @@ pub struct MetaInfo {
|
||||
realmfs_owner: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
version: u32,
|
||||
version: String,
|
||||
|
||||
#[serde(default)]
|
||||
timestamp: String,
|
||||
@@ -469,6 +494,12 @@ pub struct MetaInfo {
|
||||
|
||||
#[serde(default, rename = "verity-root")]
|
||||
verity_root: String,
|
||||
|
||||
#[serde(rename = "public-key")]
|
||||
public_key: Option<String>,
|
||||
|
||||
#[serde(rename = "authorizing-signature")]
|
||||
authorizing_signature: Option<String>,
|
||||
}
|
||||
|
||||
impl MetaInfo {
|
||||
@@ -508,8 +539,8 @@ impl MetaInfo {
|
||||
Self::str_ref(&self.realmfs_owner)
|
||||
}
|
||||
|
||||
pub fn version(&self) -> u32 {
|
||||
self.version
|
||||
pub fn version(&self) -> &str {
|
||||
&self.version
|
||||
}
|
||||
|
||||
pub fn timestamp(&self) -> &str {
|
||||
@@ -535,5 +566,13 @@ impl MetaInfo {
|
||||
pub fn verity_tag(&self) -> &str {
|
||||
&self.verity_root()[..8]
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> Option<&str> {
|
||||
self.public_key.as_deref()
|
||||
}
|
||||
|
||||
pub fn authorizing_signature(&self) -> Option<&str> {
|
||||
self.authorizing_signature.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,9 +35,24 @@ impl PublicKey {
|
||||
}
|
||||
|
||||
pub fn verify(&self, data: &[u8], signature: &[u8]) -> bool {
|
||||
let sig = sign::Signature::try_from(signature)
|
||||
.expect("Signature::from_slice() failed");
|
||||
sign::verify_detached(&sig, data, &self.0)
|
||||
let sig = match sign::Signature::try_from(signature) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
warn!("Invalid signature length: {}", signature.len());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let is_valid = sign::verify_detached(&sig, data, &self.0);
|
||||
|
||||
if !is_valid {
|
||||
warn!("Header signature verification FAILED!");
|
||||
warn!(" Public Key: {}", self.to_hex());
|
||||
warn!(" Data (header): {}", hex::encode(data));
|
||||
warn!(" Signature: {}", hex::encode(signature));
|
||||
} else {
|
||||
info!("Header signature verification SUCCESS.");
|
||||
}
|
||||
is_valid
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
#[macro_use] extern crate serde_derive;
|
||||
#[macro_use] extern crate lazy_static;
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[macro_use] pub mod error;
|
||||
#[macro_use] mod log;
|
||||
#[macro_use] mod exec;
|
||||
@@ -20,6 +23,7 @@ pub mod symlink;
|
||||
mod realm;
|
||||
pub mod terminal;
|
||||
mod system;
|
||||
pub mod updates;
|
||||
|
||||
pub mod flatpak;
|
||||
|
||||
@@ -54,28 +58,51 @@ pub fn devkeys() -> KeyPair {
|
||||
.expect("Error parsing built in dev channel keys")
|
||||
}
|
||||
|
||||
pub fn public_key_for_channel(channel: &str) -> Result<Option<PublicKey>> {
|
||||
if channel == "dev" {
|
||||
return Ok(Some(devkeys().public_key()));
|
||||
pub fn keypair_for_channel_signing(private_key_path: &Path) -> KeyPair {
|
||||
let hex_key = fs::read_to_string(private_key_path)
|
||||
.expect(&format!("Error reading secret key from {}", private_key_path.display()));
|
||||
KeyPair::from_hex(hex_key.trim())
|
||||
.expect(&format!("Error parsing secret key from {}", private_key_path.display()))
|
||||
}
|
||||
|
||||
pub fn public_key_for_channel(channel: &str) -> Result<PublicKey> {
|
||||
// Validate input first
|
||||
if !channel.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
|
||||
bail!("Invalid channel name: {}", channel);
|
||||
}
|
||||
|
||||
// Look in /etc/os-release
|
||||
if Some(channel) == OsRelease::citadel_channel() {
|
||||
if let Some(hex) = OsRelease::citadel_image_pubkey() {
|
||||
let pubkey = PublicKey::from_hex(hex)?;
|
||||
return Ok(Some(pubkey));
|
||||
}
|
||||
}
|
||||
|
||||
// Does kernel command line have citadel.channel=name:[hex encoded pubkey]
|
||||
// Kernel command line override for developers
|
||||
if Some(channel) == CommandLine::channel_name() {
|
||||
if let Some(hex) = CommandLine::channel_pubkey() {
|
||||
let pubkey = PublicKey::from_hex(hex)?;
|
||||
return Ok(Some(pubkey))
|
||||
return Ok(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
let key_path = Path::new("/usr/share/citadel/keys/").join(format!("{}_image.pub", channel));
|
||||
|
||||
if !key_path.exists() {
|
||||
if channel == "dev" {
|
||||
return Ok(devkeys().public_key());
|
||||
}
|
||||
bail!("Public key not found for channel '{}' at {}", channel, key_path.display());
|
||||
}
|
||||
|
||||
let hex_key = fs::read_to_string(&key_path)
|
||||
.map_err(context!("could not read public key from {}", key_path.display()))?;
|
||||
|
||||
let pubkey = PublicKey::from_hex(hex_key.trim())?;
|
||||
Ok(pubkey)
|
||||
}
|
||||
|
||||
pub fn root_image_public_key() -> Result<PublicKey> {
|
||||
let key_path = Path::new("/usr/share/citadel/keys/root_image.pub");
|
||||
if !key_path.exists() {
|
||||
bail!("Root image public key not found at {}", key_path.display());
|
||||
}
|
||||
let hex_key = fs::read_to_string(&key_path)
|
||||
.map_err(context!("could not read root public key from {}", key_path.display()))?;
|
||||
PublicKey::from_hex(hex_key.trim())
|
||||
}
|
||||
|
||||
pub use error::{Result,Error};
|
||||
|
||||
@@ -15,8 +15,7 @@ pub struct Partition {
|
||||
#[derive(Clone)]
|
||||
struct HeaderInfo {
|
||||
header: Arc<ImageHeader>,
|
||||
// None if no public key available for channel named in metainfo
|
||||
pubkey: Option<PublicKey>,
|
||||
pubkey: PublicKey,
|
||||
}
|
||||
|
||||
impl Partition {
|
||||
@@ -43,13 +42,7 @@ impl Partition {
|
||||
}
|
||||
|
||||
let metainfo = header.metainfo();
|
||||
let pubkey = match public_key_for_channel(metainfo.channel()) {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
warn!("Error parsing pubkey for channel '{}': {}", metainfo.channel(), err);
|
||||
None
|
||||
}
|
||||
};
|
||||
let pubkey = public_key_for_channel(metainfo.channel())?;
|
||||
|
||||
let header = Arc::new(header);
|
||||
Ok(Some(HeaderInfo {
|
||||
@@ -104,21 +97,15 @@ impl Partition {
|
||||
|
||||
pub fn is_signature_valid(&self) -> bool {
|
||||
if let Some(ref hinfo) = self.hinfo {
|
||||
if let Some(ref pubkey) = hinfo.pubkey {
|
||||
return pubkey.verify(
|
||||
&self.header().metainfo_bytes(),
|
||||
&self.header().signature())
|
||||
}
|
||||
return hinfo.pubkey.verify(
|
||||
&self.header().metainfo_bytes(),
|
||||
&self.header().signature())
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn has_public_key(&self) -> bool {
|
||||
if let Some(ref h) = self.hinfo {
|
||||
h.pubkey.is_some()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
self.hinfo.is_some()
|
||||
}
|
||||
|
||||
pub fn write_status(&mut self, status: u8) -> Result<()> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::{Path,PathBuf};
|
||||
use std::sync::{Arc, Weak, RwLock};
|
||||
|
||||
@@ -283,10 +282,7 @@ impl RealmFS {
|
||||
let pubkey = if self.metainfo().channel() == RealmFS::USER_KEYNAME {
|
||||
self.sealing_keys()?.public_key()
|
||||
} else {
|
||||
match self.header().public_key()? {
|
||||
Some(pubkey) => pubkey,
|
||||
None => bail!("No public key available for channel {}", self.metainfo().channel()),
|
||||
}
|
||||
self.header().public_key()?
|
||||
};
|
||||
Ok(pubkey)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,21 @@ impl ResourceImage {
|
||||
|
||||
info!("Searching run directory for image {} with channel {}", image_type, channel);
|
||||
|
||||
if let Some(image) = search_directory(RUN_DIRECTORY, image_type, Some(&channel))? {
|
||||
let run_channel = if CommandLine::live_mode() || CommandLine::install_mode() {
|
||||
info!("Live/Install mode: searching {} without channel filter", RUN_DIRECTORY);
|
||||
None
|
||||
} else {
|
||||
info!("Normal mode: searching {} with channel filter: {}", RUN_DIRECTORY, channel);
|
||||
Some(channel)
|
||||
};
|
||||
|
||||
// In live/install mode, skip strict kernel version/id matching since we're
|
||||
// using whatever kernel image was provided on the boot media
|
||||
let skip_kernel_match = CommandLine::live_mode() || CommandLine::install_mode();
|
||||
|
||||
info!("Searching in {}", RUN_DIRECTORY);
|
||||
if let Some(image) = search_directory(RUN_DIRECTORY, image_type, run_channel, skip_kernel_match)? {
|
||||
info!("Found image in {}: {}", RUN_DIRECTORY, image.path().display());
|
||||
return Ok(image);
|
||||
}
|
||||
|
||||
@@ -52,8 +66,9 @@ impl ResourceImage {
|
||||
|
||||
let storage_path = Path::new(STORAGE_BASEDIR).join(&channel);
|
||||
|
||||
if let Some(image) = search_directory(storage_path, image_type, Some(&channel))? {
|
||||
return Ok(image);
|
||||
if let Some(image) = search_directory(&storage_path, image_type, Some(&channel), false)? {
|
||||
info!("Found image in storage: {}", image.path().display());
|
||||
return Ok(image);
|
||||
}
|
||||
|
||||
bail!("failed to find resource image of type: {}", image_type)
|
||||
@@ -66,7 +81,7 @@ impl ResourceImage {
|
||||
|
||||
/// Locate a rootfs image in /run/citadel/images and return it
|
||||
pub fn find_rootfs() -> Result<Self> {
|
||||
match search_directory(RUN_DIRECTORY, "rootfs", None)? {
|
||||
match search_directory(RUN_DIRECTORY, "rootfs", None, false)? {
|
||||
Some(image) => Ok(image),
|
||||
None => bail!("failed to find rootfs resource image"),
|
||||
}
|
||||
@@ -199,15 +214,11 @@ impl ResourceImage {
|
||||
|
||||
pub fn setup_verity_device(&self) -> Result<String> {
|
||||
if !CommandLine::nosignatures() {
|
||||
match self.header.public_key()? {
|
||||
Some(pubkey) => {
|
||||
if !self.header.verify_signature(pubkey) {
|
||||
bail!("header signature verification failed");
|
||||
}
|
||||
info!("Image header signature is valid");
|
||||
}
|
||||
None => bail!("cannot verify header signature because no public key for channel {} is available", self.metainfo().channel())
|
||||
let pubkey = self.header.public_key()?;
|
||||
if !self.header.verify_signature(pubkey) {
|
||||
bail!("header signature verification failed");
|
||||
}
|
||||
info!("Image header signature is valid");
|
||||
}
|
||||
info!("Setting up dm-verity device for image");
|
||||
if !self.has_verity_hashtree() {
|
||||
@@ -350,6 +361,13 @@ impl ResourceImage {
|
||||
// If the /storage directory is not mounted, attempt to mount it.
|
||||
// Return true if already mounted or if the attempt to mount it succeeds.
|
||||
pub fn ensure_storage_mounted() -> Result<bool> {
|
||||
// In live/install mode, storage is a tmpfs and /dev/mapper/citadel-storage
|
||||
// doesn't exist. All resources should be in /run/citadel/images.
|
||||
if CommandLine::live_mode() || CommandLine::install_mode() {
|
||||
info!("Live/Install mode: skipping storage mount (using tmpfs)");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if Mounts::is_source_mounted("/dev/mapper/citadel-storage")? {
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -371,9 +389,30 @@ impl ResourceImage {
|
||||
}
|
||||
|
||||
fn rootfs_channel() -> &'static str {
|
||||
match CommandLine::channel_name() {
|
||||
Some(channel) => channel,
|
||||
None => "dev",
|
||||
|
||||
let cmdline_channel = CommandLine::channel_name();
|
||||
info!("CommandLine::channel_name() = {:?}", cmdline_channel);
|
||||
|
||||
match cmdline_channel {
|
||||
Some(channel) => {
|
||||
info!("Using channel from kernel command line: {}", channel);
|
||||
channel
|
||||
},
|
||||
None => {
|
||||
let osrelease_channel = OsRelease::citadel_channel();
|
||||
info!("OsRelease::citadel_channel() = {:?}", osrelease_channel);
|
||||
|
||||
match osrelease_channel {
|
||||
Some(channel) => {
|
||||
info!("Using channel from OsRelease: {}", channel);
|
||||
channel
|
||||
},
|
||||
None => {
|
||||
info!("No channel found, defaulting to 'dev'");
|
||||
"dev"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,15 +422,24 @@ impl ResourceImage {
|
||||
// in the image header metainfo. If multiple matches are found, return the image
|
||||
// with the highest version number. If multiple images have the same highest version
|
||||
// number, return the image with the newest file creation time.
|
||||
fn search_directory<P: AsRef<Path>>(dir: P, image_type: &str, channel: Option<&str>) -> Result<Option<ResourceImage>> {
|
||||
// If skip_kernel_match is true, kernel version/id matching is skipped for kernel images.
|
||||
fn search_directory<P: AsRef<Path>>(dir: P, image_type: &str, channel: Option<&str>, skip_kernel_match: bool) -> Result<Option<ResourceImage>> {
|
||||
info!("search_directory: dir={}, image_type={}, channel={:?}, skip_kernel_match={}",
|
||||
dir.as_ref().display(), image_type, channel, skip_kernel_match);
|
||||
|
||||
if !dir.as_ref().exists() {
|
||||
return Ok(None)
|
||||
}
|
||||
|
||||
let mut best = None;
|
||||
|
||||
let mut matches = all_matching_images(dir.as_ref(), image_type, channel)?;
|
||||
debug!("Found {} matching images", matches.len());
|
||||
let mut matches = all_matching_images(dir.as_ref(), image_type, channel, skip_kernel_match)?;
|
||||
info!("Found {} matching images in {}", matches.len(), dir.as_ref().display());
|
||||
|
||||
for (i, img) in matches.iter().enumerate() {
|
||||
info!(" Match {}: {} (channel: {}, version: {})",
|
||||
i, img.path().display(), img.metainfo().channel(), img.metainfo().version());
|
||||
}
|
||||
|
||||
if channel.is_none() {
|
||||
if matches.is_empty() {
|
||||
@@ -420,8 +468,10 @@ fn compare_images(a: Option<ResourceImage>, b: ResourceImage) -> Result<Resource
|
||||
None => return Ok(b),
|
||||
};
|
||||
|
||||
let ver_a = a.metainfo().version();
|
||||
let ver_b = b.metainfo().version();
|
||||
let bind_a = a.metainfo();
|
||||
let bind_b = b.metainfo();
|
||||
let ver_a = bind_a.version();
|
||||
let ver_b = bind_b.version();
|
||||
|
||||
if ver_a > ver_b {
|
||||
Ok(a)
|
||||
@@ -455,17 +505,21 @@ fn current_kernel_version() -> String {
|
||||
|
||||
//
|
||||
// Read a directory search for ResourceImages which match the channel
|
||||
// and image_type.
|
||||
// and image_type. If skip_kernel_match is true, skip kernel version/id matching.
|
||||
//
|
||||
fn all_matching_images(dir: &Path, image_type: &str, channel: Option<&str>) -> Result<Vec<ResourceImage>> {
|
||||
fn all_matching_images(dir: &Path, image_type: &str, channel: Option<&str>, skip_kernel_match: bool) -> Result<Vec<ResourceImage>> {
|
||||
let kernel_version = current_kernel_version();
|
||||
let kv = if image_type == "kernel" {
|
||||
let kv = if image_type == "kernel" && !skip_kernel_match {
|
||||
Some(kernel_version.as_str())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let kernel_id = OsRelease::citadel_kernel_id();
|
||||
let kernel_id = if !skip_kernel_match {
|
||||
OsRelease::citadel_kernel_id()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut v = Vec::new();
|
||||
util::read_directory(dir, |dent| {
|
||||
@@ -477,8 +531,9 @@ fn all_matching_images(dir: &Path, image_type: &str, channel: Option<&str>) -> R
|
||||
|
||||
// Examine a directory entry to determine if it is a resource image which
|
||||
// matches a given channel and image_type. If the image_type is "kernel"
|
||||
// then also match the kernel-version and kernel-id fields. If channel
|
||||
// is None then don't consider the channel in the match.
|
||||
// then also match the kernel-version and kernel-id fields (unless those
|
||||
// parameters are None, in which case skip version/id checking).
|
||||
// If channel is None then don't consider the channel in the match.
|
||||
//
|
||||
// If the entry is a match, then instantiate a ResourceImage and add it to
|
||||
// the images vector.
|
||||
@@ -490,9 +545,12 @@ fn maybe_add_dir_entry(entry: &DirEntry,
|
||||
images: &mut Vec<ResourceImage>) -> Result<()> {
|
||||
|
||||
let path = entry.path();
|
||||
info!("Examining directory entry: {}", path.display());
|
||||
|
||||
let meta = entry.metadata()
|
||||
.map_err(context!("failed to read metadata for {:?}", entry.path()))?;
|
||||
if !meta.is_file() || meta.len() < ImageHeader::HEADER_SIZE as u64 {
|
||||
if !meta.is_file() {
|
||||
info!(" Skipping: not a regular file");
|
||||
return Ok(())
|
||||
}
|
||||
let header = match ImageHeader::from_file(&path) {
|
||||
@@ -504,25 +562,38 @@ fn maybe_add_dir_entry(entry: &DirEntry,
|
||||
};
|
||||
|
||||
if !header.is_magic_valid() {
|
||||
info!(" Skipping: invalid magic header");
|
||||
return Ok(())
|
||||
}
|
||||
|
||||
let metainfo = header.metainfo();
|
||||
|
||||
debug!("Found an image type={} channel={} kernel={:?}", metainfo.image_type(), metainfo.channel(), metainfo.kernel_version());
|
||||
info!("Found an image type={} channel={} kernel={:?}", metainfo.image_type(), metainfo.channel(), metainfo.kernel_version());
|
||||
|
||||
if let Some(channel) = channel {
|
||||
if metainfo.channel() != channel {
|
||||
info!(" Skipping: channel mismatch (want {}, got {})", channel, metainfo.channel());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if image_type != metainfo.image_type() {
|
||||
info!(" Skipping: image_type mismatch (want {}, got {})", image_type, metainfo.image_type());
|
||||
return Ok(())
|
||||
}
|
||||
|
||||
if image_type == "kernel" && (metainfo.kernel_version() != kernel_version || metainfo.kernel_id() != kernel_id) {
|
||||
return Ok(());
|
||||
// Only check kernel version/id if they are specified (Some)
|
||||
// kernel_version must match if specified
|
||||
// kernel_id must match only if specified (if None, any kernel_id is acceptable)
|
||||
if image_type == "kernel" && kernel_version.is_some() {
|
||||
let version_matches = metainfo.kernel_version() == kernel_version;
|
||||
let id_matches = kernel_id.is_none() || metainfo.kernel_id() == kernel_id;
|
||||
|
||||
if !version_matches || !id_matches {
|
||||
info!(" Skipping: kernel version/id mismatch (want version={:?}, id={:?}; got version={:?}, id={:?})",
|
||||
kernel_version, kernel_id, metainfo.kernel_version(), metainfo.kernel_id());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
images.push(ResourceImage::new(&path, header));
|
||||
|
||||
183
libcitadel/src/updates.rs
Normal file
183
libcitadel/src/updates.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use anyhow::Context;
|
||||
use std::fmt;
|
||||
use std::io::Write;
|
||||
use std::slice::Iter;
|
||||
|
||||
pub const UPDATE_SERVER_HOSTNAME: &str = "update.subgraph.com";
|
||||
const CITADEL_CONFIG_PATH: &str = "/storage/citadel-state/citadel.conf";
|
||||
|
||||
/// This struct embeds the CitadelVersion datastruct as well as the cryptographic validation of that information
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CryptoContainerFile {
|
||||
pub serialized_citadel_version: Vec<u8>, // we serialize CitadelVersion
|
||||
pub signature: String, // serialized CitadelVersion gets signed
|
||||
pub signatory: String, // name of org or person who holds the key
|
||||
}
|
||||
|
||||
/// This struct contains the entirety of the logical information needed to decide whether to update or not
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct CitadelVersionStruct {
|
||||
pub client: String,
|
||||
pub channel: String, // dev, stable ...
|
||||
pub component_version: Vec<AvailableComponentVersion>,
|
||||
pub publisher: String, // name of org or person who released this update
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CitadelVersionStruct {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{} image with channel {} has components:\n",
|
||||
self.client, self.channel
|
||||
)?;
|
||||
for i in &self.component_version {
|
||||
write!(
|
||||
f,
|
||||
"\n{} with version {} at location {}",
|
||||
i.component, i.version, i.file_path
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone, Deserialize, PartialEq, Eq, Ord)]
|
||||
pub struct AvailableComponentVersion {
|
||||
pub component: Component, // rootfs, kernel or extra
|
||||
pub version: String, // stored as semver
|
||||
pub file_path: String,
|
||||
pub sha256_hash: String,
|
||||
}
|
||||
|
||||
impl PartialOrd for AvailableComponentVersion {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
// absolutely require that the components be in the same order in all structs (rootfs, kernel, extra)
|
||||
if &self.component != &other.component {
|
||||
panic!("ComponentVersion comparison failed because comparing different components");
|
||||
}
|
||||
Some(
|
||||
semver::Version::parse(&self.version)
|
||||
.unwrap()
|
||||
.cmp(&semver::Version::parse(&other.version).unwrap()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AvailableComponentVersion {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"({} image has version: {})",
|
||||
self.component, self.version
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, clap::ValueEnum)]
|
||||
pub enum Component {
|
||||
Rootfs,
|
||||
Kernel,
|
||||
Extra,
|
||||
}
|
||||
|
||||
impl Component {
|
||||
pub fn iterator() -> Iter<'static, Component> {
|
||||
static COMPONENTS: [Component; 3] =
|
||||
[Component::Rootfs, Component::Kernel, Component::Extra];
|
||||
COMPONENTS.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Component {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Component::Rootfs => write!(f, "rootfs"),
|
||||
Component::Kernel => write!(f, "kernel"),
|
||||
&Component::Extra => write!(f, "extra"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a specific key from an os-release formatted file.
|
||||
/// The value is returned without any surrounding quotes.
|
||||
pub fn get_citadel_conf(key: &str) -> anyhow::Result<Option<String>> {
|
||||
// Read the entire file into a string.
|
||||
let content = std::fs::read_to_string(CITADEL_CONFIG_PATH)
|
||||
.context(format!("Failed to read {}", CITADEL_CONFIG_PATH))?;
|
||||
|
||||
// Search each line for the key.
|
||||
for line in content.lines() {
|
||||
// Check if the line starts with "KEY="
|
||||
if let Some(value) = line.trim().strip_prefix(&format!("{}=", key)) {
|
||||
// If found, trim whitespace and quotes from the value and return.
|
||||
let value = value.trim().trim_matches('"').to_string();
|
||||
return Ok(Some(value));
|
||||
}
|
||||
}
|
||||
|
||||
// If the loop finishes without finding the key, return None.
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn get_os_release(key: &str) -> anyhow::Result<Option<String>> {
|
||||
// Read the entire file into a string.
|
||||
let content = std::fs::read_to_string("/etc/os-release")
|
||||
.context(format!("Failed to read {}", "/etc/os-release"))?;
|
||||
|
||||
// Search each line for the key.
|
||||
for line in content.lines() {
|
||||
// Check if the line starts with "KEY="
|
||||
if let Some(value) = line.trim().strip_prefix(&format!("{}=", key)) {
|
||||
// If found, trim whitespace and quotes from the value and return.
|
||||
let value = value.trim().trim_matches('"').to_string();
|
||||
return Ok(Some(value));
|
||||
}
|
||||
}
|
||||
|
||||
// If the loop finishes without finding the key, return None.
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Safely modifies a key-value pair in the citadel config os-release-formated file.
|
||||
/// If the key does not exist, it will be added to the end of the file.
|
||||
pub fn set_citadel_conf(key: &str, value: &str) -> anyhow::Result<()> {
|
||||
// Read the existing os-release file.
|
||||
let content = std::fs::read_to_string(CITADEL_CONFIG_PATH)
|
||||
.context(format!("Failed to read {}", CITADEL_CONFIG_PATH))?;
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let mut key_updated = false;
|
||||
let key_prefix = format!("{}=", key);
|
||||
let new_line = format!("{}{}", key_prefix, value);
|
||||
|
||||
// Process each line to update the key if it exists.
|
||||
for line in content.lines() {
|
||||
if line.starts_with(&key_prefix) {
|
||||
lines.push(new_line.clone());
|
||||
key_updated = true;
|
||||
} else {
|
||||
lines.push(line.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// If the key was not found, add it to the end.
|
||||
if !key_updated {
|
||||
lines.push(new_line);
|
||||
}
|
||||
|
||||
// Write the changes back safely using a temporary file and an atomic rename.
|
||||
let mut temp_file = tempfile::Builder::new()
|
||||
.prefix("citadel.conf")
|
||||
.suffix(".tmp")
|
||||
.tempfile_in(std::path::Path::new(CITADEL_CONFIG_PATH).parent().unwrap())?;
|
||||
|
||||
temp_file.write_all(lines.join("\n").as_bytes())?;
|
||||
temp_file.write_all(b"\n")?; // Ensure the file ends with a newline.
|
||||
|
||||
temp_file.persist(CITADEL_CONFIG_PATH).context(format!(
|
||||
"Failed to overwrite {}. Are you running as root?",
|
||||
CITADEL_CONFIG_PATH
|
||||
))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -48,6 +48,12 @@ fn search_path(filename: &str) -> Result<PathBuf> {
|
||||
bail!("could not find {} in $PATH", filename)
|
||||
}
|
||||
|
||||
pub fn append_to_path(p: &Path, s: &str) -> PathBuf {
|
||||
let mut p_osstr = p.as_os_str().to_owned();
|
||||
p_osstr.push(s);
|
||||
p_osstr.into()
|
||||
}
|
||||
|
||||
pub fn ensure_command_exists(cmd: &str) -> Result<()> {
|
||||
let path = Path::new(cmd);
|
||||
if !path.is_absolute() {
|
||||
@@ -404,4 +410,4 @@ pub fn drop_privileges(uid: u32, gid: u32) -> Result<()> {
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user