1
0
forked from brl/citadel-tools

5 Commits

Author SHA1 Message Date
isa
43f0e3ff98 Add channels and per-channel key signing 2025-09-25 22:17:33 -04:00
isa
991621d489 Make text clearer when checking hash 2025-09-04 16:35:59 -04:00
isa
08460b3d5e Fix broken extra image search 2025-08-29 00:58:06 -04:00
isa
d6a93b3ded Add basic update tooling 2025-08-28 12:34:47 -04:00
isa
756520821e Convert images version to use semver 2025-08-28 00:52:07 -04:00
20 changed files with 2105 additions and 97 deletions

1201
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[workspace]
members = ["citadel-realms", "citadel-tool", "realmsd", "launch-gnome-software", "update-realmfs" ]
members = ["citadel-realms", "citadel-tool", "realmsd", "launch-gnome-software", "update-realmfs"]
resolver = "2"
[profile.release]
lto = true

View File

@@ -24,4 +24,14 @@ 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"]}
base64ct = "=1.7.3"
reqwest = { version = "0.12", features = ["blocking"] }
sha2 = "0.10"
nix = "0.30"
dialoguer = "0.12"
indicatif = "0.18"

View File

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

View File

@@ -0,0 +1,529 @@
use crate::{update, Path};
use anyhow::{Context, Result};
use clap::ArgMatches;
use dialoguer::{theme::ColorfulTheme, Confirm, MultiSelect};
use ed25519_dalek::{pkcs8::DecodePublicKey, VerifyingKey};
use indicatif::{ProgressBar, ProgressStyle};
use libcitadel::ResourceImage;
use libcitadel::{updates, updates::CitadelVersionStruct};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::env;
use std::fs;
use std::io::prelude::*;
use std::str::FromStr;
use tempfile::Builder;
const IMAGE_DIRECTORY_PATH: &str = "/storage/resources";
const UPDATE_SERVER_KEY_PATH: &str = "/etc/citadel/update_server_key.pub";
const LAST_RESORT_CLIENT: &str = "public";
const LAST_RESORT_CHANNEL: &str = "stable";
const LAST_RESORT_CITADEL_PUBLISHER: &str = "Subgraph";
const DEFAULT_UPDATE_SERVER_HOSTNAME: &str = "update.subgraph.com";
#[derive(Deserialize)]
struct Channels {
channels: Vec<String>,
}
fn get_update_server_hostname() -> String {
env::var("UPDATE_SERVER_HOSTNAME")
.unwrap_or_else(|_| DEFAULT_UPDATE_SERVER_HOSTNAME.to_string())
}
fn verify_hash(path: &std::path::PathBuf, expected_hash: &str) -> Result<()> {
let mut file = fs::File::open(path)?;
let mut sha256 = Sha256::new();
std::io::copy(&mut file, &mut sha256)?;
let hash = format!("{:x}", sha256.finalize());
if hash != expected_hash {
fs::remove_file(path)?;
anyhow::bail!(
"Hash mismatch for file {}. Expected {}, got {}",
path.display(),
expected_hash,
hash
);
}
Ok(())
}
pub fn check() -> Result<()> {
let current_version = get_current_os_config()?;
let server_citadel_version = fetch_and_verify_version_cbor(&current_version)?;
let components_to_upgrade =
compare_citadel_versions(&current_version, &server_citadel_version)?;
if components_to_upgrade.len() == 1 {
println!(
"We found the following component to upgrade: {}",
components_to_upgrade[0]
);
} else if components_to_upgrade.len() > 1 {
println!("We found the following components to upgrade: \n");
for component in components_to_upgrade {
println!("{}", component);
}
} else {
println!("Your system is up to date!");
}
Ok(())
}
fn get_component_info_from_args<'a>(
sub_matches: &ArgMatches,
server_version: &'a CitadelVersionStruct,
) -> Result<(&'a str, &'a str)> {
let (path, hash) = if sub_matches.get_flag("rootfs") {
(
&server_version.component_version[0].file_path,
&server_version.component_version[0].sha256_hash,
)
} else if sub_matches.get_flag("kernel") {
(
&server_version.component_version[1].file_path,
&server_version.component_version[1].sha256_hash,
)
} else if sub_matches.get_flag("extra") {
(
&server_version.component_version[2].file_path,
&server_version.component_version[2].sha256_hash,
)
} else {
anyhow::bail!("No component specified for download/reinstall.");
};
Ok((path, hash))
}
pub fn download(sub_matches: &ArgMatches) -> Result<()> {
let current_version = &get_current_os_config()?;
let server_citadel_version = &fetch_and_verify_version_cbor(current_version)?;
let (path, hash) = get_component_info_from_args(sub_matches, server_citadel_version)?;
download_file(path, hash)?;
Ok(())
}
pub fn read_remote() -> Result<()> {
let server_citadel_version = fetch_and_verify_version_cbor(&get_current_os_config()?)?;
println!("Server offers:\n{}", server_citadel_version);
Ok(())
}
pub fn upgrade() -> Result<()> {
// First, get access to the current citadel's parameters
let current_version = &get_current_os_config()?;
// Check if we are missing local version info, which happens after a channel switch
let mut missing_components = Vec::new();
for component in &current_version.component_version {
if (component.component == updates::Component::Kernel
|| component.component == updates::Component::Extra)
&& component.version == "0.0.0"
{
missing_components.push(component.component.to_string());
}
}
if !missing_components.is_empty() {
println!("WARNING: Your system is missing local version information for the following components:");
for comp in &missing_components {
println!("- {}", comp);
}
println!(
"\nThis is expected if you have recently switched to the '{}' update channel.",
current_version.channel
);
println!("However, it means we cannot verify if the server's version is newer than your installed version.");
println!(
"This could expose you to a downgrade attack if the remote channel is compromised."
);
let confirmed = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(
"Do you want to proceed with fetching and installing the versions from the server?",
)
.default(false)
.interact()?;
if !confirmed {
println!("Upgrade aborted by user.");
return Ok(());
}
}
let server_citadel_version = &fetch_and_verify_version_cbor(current_version)?;
// Find which components have updates available
let components_to_upgrade = compare_citadel_versions(current_version, server_citadel_version)?;
if components_to_upgrade.is_empty() {
println!("Your system is up to date!");
return Ok(());
}
// Create a list of formatted strings for the prompt
let prompt_items: Vec<String> = components_to_upgrade
.iter()
.map(|comp| {
// Find the currently installed version for a nice display
let current_comp_version = current_version
.component_version
.iter()
.find(|c| c.component == comp.component)
.map(|c| c.version.clone())
.unwrap_or_else(|| "N/A".to_string());
// If the current version is 0.0.0, display it as "not installed"
let display_version = if current_comp_version == "0.0.0" {
"not installed".to_string()
} else {
current_comp_version
};
format!(
"{} ({} -> {})",
comp.component, display_version, comp.version
)
})
.collect();
// Build and display the interactive checklist to the user
println!("Found available updates. Please make your selection:");
let selections = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt("Select components to upgrade (use spacebar to select, enter to confirm)")
.items(&prompt_items)
.defaults(&vec![true; prompt_items.len()])
.interact()?;
if selections.is_empty() {
println!("No components selected. Aborting upgrade.");
return Ok(());
}
// Create a final list of only the components the user selected
let mut final_components_to_install = Vec::new();
for index in selections {
final_components_to_install.push(components_to_upgrade[index].clone());
}
println!(
"\nPreparing to install {} component(s)...",
final_components_to_install.len()
);
// Loop through the selected components and install them
for component in final_components_to_install {
println!("---");
println!("Upgrading {}", component.component);
let (_tmp_dir, save_path) = download_file(&component.file_path, &component.sha256_hash)?;
println!("Installing image...");
update::install_image(&save_path, 0)?;
println!("{} installed successfully!", component.component);
}
println!("---");
println!("Update process finished.");
Ok(())
}
pub fn reinstall(sub_matches: &ArgMatches) -> Result<()> {
let current_version = &get_current_os_config()?;
let server_citadel_version = &fetch_and_verify_version_cbor(current_version)?;
let (path, hash) = get_component_info_from_args(sub_matches, server_citadel_version)?;
let (_tmp_dir, save_path) = download_file(path, hash)?;
update::install_image(&save_path, 0)?;
Ok(())
}
pub fn status() -> Result<()> {
println!("Gathering local system information...");
let current_config = get_current_os_config()?;
println!("\n--- Citadel Update Status ---");
println!("Client: {}", current_config.client);
println!("Channel: {}", current_config.channel);
println!("Publisher: {}", current_config.publisher);
println!("\n--- Installed Components ---");
for component in current_config.component_version {
// Handle the case where a component might not be installed for the current channel
let version_display = if component.version == "0.0.0" {
"Not installed for this channel".to_string()
} else {
component.version
};
println!(
"{:<12}{}",
format!("{}:", component.component),
version_display
);
}
Ok(())
}
/// Returns a vec of ComponentVersion structs of the components which can be upgraded
fn compare_citadel_versions(
current: &CitadelVersionStruct,
offered: &CitadelVersionStruct,
) -> Result<Vec<updates::AvailableComponentVersion>> {
let mut update_vec: Vec<updates::AvailableComponentVersion> = Vec::new();
if current.channel != offered.channel {
anyhow::bail!(
"Update channel mismatch. Your system is on '{}', but the server is configured for '{}'. Please check your configuration.",
current.channel,
offered.channel
);
}
if current.client != offered.client {
anyhow::bail!(
"Update client mismatch. Your system is configured as '{}', but the server is configured for '{}'. Please check your configuration.",
current.client,
offered.client
);
}
if current.publisher != offered.publisher {
anyhow::bail!(
"Update publisher mismatch. Your system is configured for '{}', but the server is configured for '{}'. Please check your configuration.",
current.publisher,
offered.publisher
);
}
for i in 0..current.component_version.len() {
if current.component_version[i] < offered.component_version[i] {
update_vec.push(offered.component_version[i].clone());
}
}
Ok(update_vec)
}
fn get_image_version<P: AsRef<Path>>(path: &P) -> Result<String> {
let resource_image = ResourceImage::from_path(path)?;
Ok(resource_image.metainfo().version().to_string())
}
/// Reads and displays the currently configured update channel.
pub fn show_channel() -> Result<()> {
let channel = updates::get_citadel_conf("CITADEL_CHANNEL")?
.unwrap_or_else(|| LAST_RESORT_CHANNEL.to_string());
println!("Current update channel: {}", channel);
Ok(())
}
/// Sets the system's update channel by writing to the config file.
pub fn set_channel(channel_to_set: &str) -> Result<()> {
// Validate the channel name against the server's list.
let available_channels = fetch_available_channels()?;
if !available_channels.contains(&channel_to_set.to_string()) {
anyhow::bail!(
"Channel '{}' is not a valid remote channel. Use 'channel list' to see options.",
channel_to_set
);
}
// Check if the directory for the new channel already exists.
let channel_dir_path = format!("{IMAGE_DIRECTORY_PATH}/{channel_to_set}");
let path = Path::new(&channel_dir_path);
if !path.exists() {
println!(
"You are switching to a new channel ('{}') for the first time.",
channel_to_set
);
let confirmed = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Are you sure you want to change channels to: '{}'?",
channel_dir_path
))
.default(true)
.interact()?;
if confirmed {
fs::create_dir_all(path).context(format!(
"Failed to create directory for channel '{}'",
channel_to_set
))?;
println!("Directory created.");
} else {
println!("Channel switch aborted by user.");
return Ok(());
}
}
updates::set_citadel_conf("CITADEL_CHANNEL", channel_to_set)?;
println!("\nUpdate channel has been set to: {}", channel_to_set);
Ok(())
}
fn get_current_os_config() -> Result<updates::CitadelVersionStruct> {
let client = updates::get_citadel_conf("CITADEL_CLIENT")?
.unwrap_or_else(|| LAST_RESORT_CLIENT.to_string());
let channel = updates::get_citadel_conf("CITADEL_CHANNEL")?
.unwrap_or_else(|| LAST_RESORT_CHANNEL.to_string());
let mut kernel_version = String::from("0.0.0");
let glob_pattern = format!("{IMAGE_DIRECTORY_PATH}/{channel}/citadel-kernel*.img");
if let Ok(glob_results) = glob::glob(&glob_pattern) {
for path_result in glob_results {
if let Ok(path) = path_result {
// If we find a kernel image, try to get its version.
// If we succeed, update the version and stop looking.
if let Ok(version) = get_image_version(&path) {
kernel_version = version;
break;
}
}
}
}
// RootFS version is always available from the running system
let rootfs_version = updates::get_os_release("CITADEL_ROOTFS_VERSION")?.unwrap();
// Extra version - gracefully handle if not found
let mut extra_version = String::from("0.0.0");
let glob_pattern_extra = format!("{IMAGE_DIRECTORY_PATH}/{channel}/citadel-extra*.img");
if let Ok(glob_results) = glob::glob(&glob_pattern_extra) {
for path_result in glob_results {
if let Ok(path) = path_result {
if let Ok(version) = get_image_version(&path) {
extra_version = version;
break;
}
}
}
}
let publisher = updates::get_citadel_conf("CITADEL_PUBLISHER")?
.unwrap_or_else(|| LAST_RESORT_CITADEL_PUBLISHER.to_string());
let mut component_version = Vec::new();
component_version.push(updates::AvailableComponentVersion {
component: updates::Component::Rootfs,
version: rootfs_version.to_owned(),
file_path: "".to_owned(),
sha256_hash: "".to_owned(),
});
component_version.push(updates::AvailableComponentVersion {
component: updates::Component::Kernel,
version: kernel_version.to_owned(),
file_path: "".to_owned(),
sha256_hash: "".to_owned(),
});
component_version.push(updates::AvailableComponentVersion {
component: updates::Component::Extra,
version: extra_version.to_owned(),
file_path: "".to_owned(),
sha256_hash: "".to_owned(),
});
let current_version_struct = updates::CitadelVersionStruct {
client: client.to_owned(),
channel: channel.to_owned(),
component_version,
publisher: publisher.to_owned(),
};
Ok(current_version_struct)
}
fn fetch_and_verify_version_cbor(
current_citadel_version: &updates::CitadelVersionStruct,
) -> Result<updates::CitadelVersionStruct> {
let url = format!(
"https://{}/{}/{}/version.cbor",
get_update_server_hostname(),
current_citadel_version.client,
current_citadel_version.channel
);
let version_file_bytes = reqwest::blocking::get(&url)?
.bytes()
.context(format!("Failed to get version_file_bytes from {}", url))?;
let crypto_container: updates::CryptoContainerFile =
serde_cbor::from_slice(&version_file_bytes)
.context(format!("Failed to parse version.cbor from {}", url))?;
// find update server public key kept in the rootfs
let mut file = std::fs::File::open(UPDATE_SERVER_KEY_PATH).context(format!(
"Failed to open update_server_key file from {}",
UPDATE_SERVER_KEY_PATH
))?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let public_key = VerifyingKey::from_public_key_pem(&contents)
.context("Failed to parse public key from file.")?;
let signature = ed25519_dalek::Signature::from_str(&crypto_container.signature)?;
// verify signature
public_key.verify_strict(&crypto_container.serialized_citadel_version, &signature)?;
// construct the struct
let citadel_version_struct: updates::CitadelVersionStruct =
serde_cbor::from_slice(&crypto_container.serialized_citadel_version)?;
Ok(citadel_version_struct)
}
fn download_file(path: &str, hash: &str) -> Result<(tempfile::TempDir, std::path::PathBuf)> {
let client = reqwest::blocking::Client::new();
let url = format!("https://{}/{}", get_update_server_hostname(), path);
println!("Downloading from {}", url);
let component_download_response = client.get(&url).send()?;
// Get the total size of the file from the server's response headers.
let total_size = component_download_response
.content_length()
.context("Failed to get content length from server")?;
// Create a new progress bar and set its style.
let pb = ProgressBar::new(total_size);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")?
.tick_strings(&["", "", "", "", "", "", "", "", "", "", " "])
.progress_chars("=>-"),
);
// Create the temporary directory and destination file.
let tmp_dir = Builder::new().prefix("citadel-fetch").tempdir()?;
let file_name = Path::new(path).file_name().unwrap();
let dest_path = tmp_dir.path().join(file_name);
let mut dest_file = fs::File::create(&dest_path)?;
// Wrap the download stream with the progress bar.
let mut source = pb.wrap_read(component_download_response);
// Copy the stream to the file, which automatically updates the progress bar.
std::io::copy(&mut source, &mut dest_file)?;
println!("\nSaved file to {}\n", dest_path.display());
verify_hash(&dest_path, hash)?;
println!("File hash verified\n");
Ok((tmp_dir, dest_path))
}
pub fn list_channels() -> Result<()> {
println!("Fetching available channels...");
let channels = fetch_available_channels()?;
println!("Available channels:");
for channel in channels {
println!("- {}", channel);
}
Ok(())
}
/// Fetches the list of available channels from the remote server.
fn fetch_available_channels() -> Result<Vec<String>> {
let url = format!(
"https://{}/{}/channels.cbor",
get_update_server_hostname(),
LAST_RESORT_CLIENT
);
let response_bytes = reqwest::blocking::get(&url)?.bytes()?;
let available_channels: Channels = serde_cbor::from_slice(&response_bytes)
.context("Failed to parse channel list CBOR from server")?;
Ok(available_channels.channels)
}

View File

@@ -0,0 +1,85 @@
use clap::{arg, command, ArgAction, Command};
use std::process::exit;
mod fetch;
use clap::Arg;
pub fn main() {
let matches = command!() // requires `cargo` feature
.subcommand_required(true)
.subcommand(Command::new("check").about("Check for updates from remote server"))
.subcommand(Command::new("status").about("Show the currently installed versions and update channel"))
.subcommand(
Command::new("download")
.about("Download a specific component from the server")
.arg(arg!(-r --rootfs "rootfs component").action(ArgAction::SetTrue))
.arg(arg!(-k --kernel "kernel component").action(ArgAction::SetTrue))
.arg(arg!(-e --extra "extra component").action(ArgAction::SetTrue))
.arg_required_else_help(true),
)
.subcommand(
Command::new("read-remote")
.about("Read the remote server and print information on versions offered"),
)
.subcommand(
Command::new("upgrade")
.about("Download and install components if the server has a more recent version than currently installed on the system")
)
.subcommand(
Command::new("reinstall")
.about("Download and install a specific component even if the server's component version is not greater than currently installed")
.arg(arg!(-r --rootfs "rootfs component").action(ArgAction::SetTrue))
.arg(arg!(-k --kernel "kernel component").action(ArgAction::SetTrue))
.arg(arg!(-e --extra "extra component").action(ArgAction::SetTrue))
.arg_required_else_help(true),
)
.subcommand(
Command::new("channel")
.about("Manage the update channel")
.subcommand_required(true)
.subcommand(Command::new("list").about("List available channels from the server"))
.subcommand(Command::new("show").about("Show the current update channel"))
.subcommand(
Command::new("set")
.about("Set a new update channel (requires root)")
.arg(Arg::new("CHANNEL").required(true)),
),
)
.get_matches();
let result = match matches.subcommand() {
Some(("check", _sub_matches)) => fetch::check(),
Some(("status", _sub_matches)) => fetch::status(),
Some(("download", sub_matches)) => fetch::download(sub_matches),
Some(("read-remote", _sub_matches)) => fetch::read_remote(),
Some(("upgrade", _sub_matches)) => {
let _ = require_root(); // Check for root privileges
fetch::upgrade()
}
Some(("reinstall", sub_matches)) => {
let _ = require_root(); // Check for root privileges.
fetch::reinstall(sub_matches)
}
Some(("channel", sub_matches)) => match sub_matches.subcommand() {
Some(("list", _)) => fetch::list_channels(),
Some(("show", _)) => fetch::show_channel(),
Some(("set", set_matches)) => {
let _ = require_root();
let channel_name = set_matches.get_one::<String>("CHANNEL").unwrap();
fetch::set_channel(channel_name)
}
_ => unreachable!(),
},
_ => unreachable!("Please pass a subcommand"),
};
if let Err(ref e) = result {
eprintln!("Error: {}", e);
exit(1);
}
}
fn require_root() -> anyhow::Result<()> {
if !nix::unistd::geteuid().is_root() {
// Using anyhow::bail! is a concise way to return an error.
anyhow::bail!("This command requires root privileges. Please try again with sudo.");
}
Ok(())
}

View File

@@ -114,15 +114,15 @@ fn info_signature(img: &ResourceImage) -> Result<()> {
} else {
println!("Signature: No Signature");
}
match img.header().public_key()? {
Some(pubkey) => {
match img.header().public_key() {
Ok(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()) },
Err(_) => { println!("No public key found for channel '{}'", img.metainfo().channel()) },
}
Ok(())
}
@@ -250,9 +250,9 @@ 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()) {

View File

@@ -16,6 +16,7 @@ mod mkimage;
mod realmfs;
mod sync;
mod update;
mod fetch;
fn main() {
let exe = match env::current_exe() {
@@ -39,6 +40,8 @@ fn main() {
realmfs::main();
} 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") {

View File

@@ -3,7 +3,7 @@ use std::fs::OpenOptions;
use std::fs::{self,File};
use std::io::{self,Write};
use libcitadel::{Result, ImageHeader, devkeys, util};
use libcitadel::{Result, ImageHeader, devkeys, util, keypair_for_channel_signing};
use super::config::BuildConfig;
use std::path::Path;
@@ -38,15 +38,15 @@ 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 +154,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 +192,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,7 +230,7 @@ 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())?;

View File

@@ -9,7 +9,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 +22,9 @@ pub struct BuildConfig {
#[serde(rename = "realmfs-name")]
realmfs_name: Option<String>,
#[serde(rename = "private-key-path")]
private_key_path: Option<String>,
#[serde(skip)]
basedir: PathBuf,
#[serde(skip)]
@@ -102,8 +105,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 +120,8 @@ 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())
}
}

View File

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

View File

@@ -20,6 +20,10 @@ 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"
[dependencies.inotify]
version = "0.8"

View File

@@ -382,7 +382,7 @@ impl ImageHeader {
self.set_signature(&zeros);
}
pub fn public_key(&self) -> Result<Option<PublicKey>> {
pub fn public_key(&self) -> Result<PublicKey> {
public_key_for_channel(self.metainfo().channel())
}
@@ -453,7 +453,7 @@ pub struct MetaInfo {
realmfs_owner: Option<String>,
#[serde(default)]
version: u32,
version: String,
#[serde(default)]
timestamp: String,
@@ -508,8 +508,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 {

View File

@@ -37,7 +37,17 @@ 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 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
}
}

View File

@@ -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,36 @@ 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()))
}
// 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]
pub fn public_key_for_channel(channel: &str) -> Result<PublicKey> {
// 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!("{}.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 use error::{Result,Error};

View File

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

View File

@@ -281,10 +281,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)
}

View File

@@ -199,15 +199,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() {
@@ -373,7 +369,10 @@ impl ResourceImage {
fn rootfs_channel() -> &'static str {
match CommandLine::channel_name() {
Some(channel) => channel,
None => "dev",
None => match OsRelease::citadel_channel() {
Some(channel) => channel,
None => "dev",
},
}
}
}
@@ -420,8 +419,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)

183
libcitadel/src/updates.rs Normal file
View 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 the 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(())
}

View File

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