idiomatic error handling #1

Closed
ramrem wants to merge 3 commits from idiomatic_error_handling into master
5 changed files with 176 additions and 97 deletions

34
dtrfs_api/Cargo.lock generated

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "actix-codec"
@ -258,12 +258,6 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "anyhow"
version = "1.0.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775"
[[package]]
name = "autocfg"
version = "1.4.0"
@ -667,7 +661,6 @@ name = "dtrfs_api"
version = "0.1.0"
dependencies = [
"actix-web",
"anyhow",
"base64",
"bincode",
"ed25519-dalek",
@ -678,6 +671,7 @@ dependencies = [
"serde",
"sev",
"sha3",
"thiserror 2.0.10",
]
[[package]]
@ -1601,7 +1595,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom",
"libredox",
"thiserror",
"thiserror 1.0.69",
]
[[package]]
@ -2026,7 +2020,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3ac7f54ca534db81081ef1c1e7f6ea8a3ef428d2fc069097c079443d24124d3"
dependencies = [
"thiserror-impl 2.0.10",
]
[[package]]
@ -2040,6 +2043,17 @@ dependencies = [
"syn",
]
[[package]]
name = "thiserror-impl"
version = "2.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9465d30713b56a37ede7185763c3492a91be2f5fa68d958c44e41ab9248beb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "time"
version = "0.3.36"

@ -4,9 +4,9 @@ version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.93"
base64 = "0.22.1"
bincode = "1.3.3"
thiserror = "2.0.10"
regex = "1.11.1"
sev = { version = "4.0", default-features = false, features = ['crypto_nossl','snp'] }
ed25519-dalek = { version = "2.1.1", features = ["pem", "pkcs8"] }

@ -1,8 +1,10 @@
mod os;
mod snp;
use actix_web::{get, post, web, App, HttpRequest, HttpResponse, HttpServer};
use crate::os::OsError;
use actix_web::{get, post, web, App, Error, HttpRequest, HttpResponse, HttpServer, ResponseError};
use base64::prelude::{Engine, BASE64_URL_SAFE};
use thiserror::Error;
use ed25519_dalek::{pkcs8::DecodePublicKey, Signature, Verifier, VerifyingKey};
use lazy_static::lazy_static;
use regex::Regex;
@ -15,12 +17,42 @@ use std::{
io::{BufReader, Read},
};
#[derive(Debug, Error)]
pub enum DtrfsError {
#[error("OS error: {0}")]
OsError(#[from] OsError),
#[error("SNP error: {0}")]
SnpError(#[from] snp::SNPError),
#[error("Could not find admin key in cmdline")]
AdminKeyNotFound,
#[error("Could not parse verifying key: {0}")]
VerifyingKeyParsingError(#[from] ed25519_dalek::pkcs8::spki::Error),
#[error("Could not get signature from request")]
SignatureNotFound,
#[error("Base64 decoding error: {0}")]
Base64Error(#[from] base64::DecodeError),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Error slicing into bytes: {0}")]
SliceError(#[from] std::array::TryFromSliceError),
#[error("Error verifying signature: {0}")]
SignatureVerificationError(#[from] ed25519_dalek::SignatureError),
}
impl ResponseError for DtrfsError {
fn error_response(&self) -> HttpResponse {
match self {
error => HttpResponse::InternalServerError().body(format!("{}", error)),
Review

Is it possible to respect HTTP error codes? Link here: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes

Maybe a match on DtrfsError?

Is it possible to respect HTTP error codes? Link here: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes Maybe a match on DtrfsError?
}
}
}
const CRT_FILE: &str = "/tmp/certs/dtrfs_api.crt";
const KEY_FILE: &str = "/tmp/certs/dtrfs_api.key";
const CMDLINE_FILE: &str = "/proc/cmdline";
lazy_static! {
static ref SNP_REPORT: String = snp::get_report_as_base64(get_cert_hash()).unwrap();
static ref SNP_REPORT: String = snp::get_report_as_base64(get_cert_hash().unwrap()).unwrap();
static ref CRT_CONTENTS: String = {
let mut msg = String::new();
let _ = BufReader::new(File::open(CRT_FILE).unwrap()).read_to_string(&mut msg);
@ -33,32 +65,32 @@ lazy_static! {
};
}
fn get_cert_hash() -> [u8; 64] {
fn get_cert_hash() -> Result<[u8; 64], DtrfsError> {
let mut hasher = Sha3_512::new();
let crt = File::open(CRT_FILE).expect("Could not open crt file.");
Review

I see this error message got removed. In case dtrfs fails now, what error will be displayed in the console of the VM?
If someone runs this code to troubleshoot potential issues or to expand functionality, will that person receive an error informing about the missing certificate?

I see this error message got removed. In case dtrfs fails now, what error will be displayed in the console of the VM? If someone runs this code to troubleshoot potential issues or to expand functionality, will that person receive an error informing about the missing certificate?
let crt = File::open(CRT_FILE)?;
let mut buf_reader = BufReader::new(crt);
let mut buffer = Vec::new();
buf_reader.read_to_end(&mut buffer).expect("Could not read certificate.");
buf_reader.read_to_end(&mut buffer)?;
hasher.update(buffer);
let crt_hash = hasher.finalize();
crt_hash.into()
let crt_hash: [u8; 64] = hasher.finalize().into();
Ok(crt_hash)
}
fn verifying_key() -> Result<VerifyingKey, Box<dyn std::error::Error>> {
let re = Regex::new(r"detee_admin=([A-Za-z0-9+/=]+)").unwrap();
fn verifying_key() -> Result<VerifyingKey, DtrfsError> {
let re = Regex::new(r"detee_admin=([A-Za-z0-9+/=]+)").unwrap(); //unwrap here is ok since the regex is hardcoded and can only fail if the regex is bigger than the configured size limit via RegexBuilder::size_limit
let key_str = re.find(&CMDLINE).map(|m| m.as_str()).unwrap_or("");
let key_pem = format!(
"-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----\n",
key_str.strip_prefix("detee_admin=").ok_or("Could not get admin key from cmdline")?
key_str.strip_prefix("detee_admin=").ok_or(DtrfsError::AdminKeyNotFound)?
);
Ok(VerifyingKey::from_public_key_pem(&key_pem)?)
}
fn verify(req: &HttpRequest) -> Result<(), Box<dyn std::error::Error>> {
fn verify(req: &HttpRequest) -> Result<(), DtrfsError> {
let signature = req
.headers()
.get("ed25519-signature")
.ok_or_else(|| "Did not find ed25519-signature header")?;
.ok_or_else(|| DtrfsError::SignatureNotFound)?;
let signature: &[u8] = &BASE64_URL_SAFE.decode(signature)?;
let signature = Signature::from_bytes(signature.try_into()?);
@ -89,14 +121,14 @@ struct InstallForm {
// TODO: QA this function to make sure we don't accidentally allow empty string keyfile
#[post("/install")]
async fn post_install_form(req: HttpRequest, form: web::Form<InstallForm>) -> HttpResponse {
if let Err(e) = verify(&req) {
return HttpResponse::BadRequest().body(format!("Signature verification failed: {}", e));
};
match os::encrypt_and_install_os(&form.url, &form.sha, &form.keyfile) {
Ok(s) => HttpResponse::Ok().body(s),
Err(e) => HttpResponse::InternalServerError().body(format!("{e:?}")),
}
async fn post_install_form(
req: HttpRequest,
form: web::Form<InstallForm>,
) -> Result<HttpResponse, Error> {
verify(&req)?;// TODO: This is temporary, we need to merget from the other branch
Review

Not sure what is temporary.

Not sure what is temporary.
os::encrypt_and_install_os(&form.url, &form.sha, &form.keyfile)
.map(|msg| HttpResponse::Ok().body(msg))
.map_err(|e| DtrfsError::OsError(e).into())
}
#[derive(Deserialize)]
@ -106,24 +138,18 @@ struct DecryptForm {
// TODO: QA this function to make sure we don't accidentally allow empty string keyfile
#[post("/decrypt")]
async fn post_decrypt_form(req: HttpRequest, form: web::Form<DecryptForm>) -> HttpResponse {
if let Err(e) = verify(&req) {
return HttpResponse::BadRequest().body(format!("Signature verification failed: {}", e));
};
let decrypt_result = os::try_backup_keyfile(&form.keyfile);
if let Err(decryption_error) = decrypt_result {
return HttpResponse::BadRequest()
.body(format!("Could not decrypt root: {decryption_error:?}"));
Review

Did we a test with a corrupted disk to see what error is displayed for the CLI?

Did we a test with a corrupted disk to see what error is displayed for the CLI?
}
let hot_key_result = os::replace_hot_keyfile();
HttpResponse::Ok().body(format!("{:?}\n{:?}", decrypt_result, hot_key_result))
async fn post_decrypt_form(req: HttpRequest, form: web::Form<DecryptForm>) -> Result<HttpResponse, Error> {
verify(&req)?;
let decrypt_result = os::try_backup_keyfile(&form.keyfile).map_err(DtrfsError::from)?;
let hot_key_result = os::replace_hot_keyfile().map_err(DtrfsError::from)?;
Ok(HttpResponse::Ok().body(format!("{:?}\n{:?}", decrypt_result, hot_key_result)))
}
#[post("/switch_root")]
async fn post_process_exit(req: HttpRequest) -> HttpResponse {
if let Err(e) = verify(&req) {
return HttpResponse::BadRequest().body(format!("Signature verification failed: {}", e));
};
async fn post_process_exit(req: HttpRequest) -> Result<HttpResponse, Error> {
verify(&req)?;
std::process::exit(0);
}
@ -133,26 +159,22 @@ struct SSHKeyForm {
}
#[get("/ssh_key")]
async fn get_ssh_keys(req: HttpRequest) -> HttpResponse {
if let Err(e) = verify(&req) {
return HttpResponse::BadRequest().body(format!("Signature verification failed: {}", e));
};
match os::list_ssh_keys() {
Ok(keys) => HttpResponse::Ok().body(keys),
Err(e) => HttpResponse::BadRequest().body(format!("{e:?}")),
}
async fn get_ssh_keys(req: HttpRequest) -> Result<HttpResponse, Error> {
verify(&req)?;
os::list_ssh_keys()
.map(|keys| HttpResponse::Ok().body(keys))
.map_err(|e| DtrfsError::OsError(e).into())
}
#[post("/ssh_key")]
async fn post_ssh_key(req: HttpRequest, form: web::Form<SSHKeyForm>) -> HttpResponse {
if let Err(e) = verify(&req) {
return HttpResponse::BadRequest().body(format!("Signature verification failed: {}", e));
};
async fn post_ssh_key(
req: HttpRequest,
form: web::Form<SSHKeyForm>,
) -> Result<HttpResponse, Error> {
verify(&req)?;
let ssh_key = &form.ssh_key;
match os::add_ssh_key(ssh_key) {
Ok(()) => HttpResponse::Ok().body("Key added to authorized_keys"),
Err(e) => HttpResponse::BadRequest().body(format!("{e:?}")),
}
os::add_ssh_key(ssh_key).map_err(DtrfsError::from)?;
Ok(HttpResponse::Ok().body("SSH key added successfully"))
}
fn load_rustls_config() -> rustls::ServerConfig {
@ -183,7 +205,7 @@ async fn main() -> std::io::Result<()> {
Ok(_) => {
println!("Hot decryption successful. Booting OS...");
return Ok(());
},
}
Err(e) => {
println!("Hot decryption failed: {e:?}");
}

@ -1,13 +1,45 @@
use crate::snp::get_derived_key;
use anyhow::{anyhow, Result};
use base64::prelude::{Engine, BASE64_URL_SAFE};
use crate::snp::{get_derived_key, SNPError};
use base64::{
prelude::{Engine, BASE64_URL_SAFE},
DecodeError,
};
use thiserror::Error;
use std::{
fs::File,
io::{BufRead, BufReader, Write},
io::{self, BufRead, BufReader, Write},
path::Path,
process::Command,
string::FromUtf8Error,
};
#[derive(Debug, Error)]
pub enum OsError {
#[error(
"OS installation script failed.\nScript stdout:\n{stdout}\nScript stderr:\n{stderr}"
)]
InstallationFailed { stdout: String, stderr: String },
#[error("Could not decrypt disk.")]
DecryptionFailed,
#[error("Could not mount /dev/mapper/root to /mnt")]
MountFailed,
#[error("Could not try hot keyfile: {0}")]
TryHotKeyfileFailed(#[from] SNPError),
#[error("Could not replace hot keyfile using SNP KDF.")]
ReplaceHotKeyfileFailed,
#[error("Operating system not mounted. Please install OS or decrypt existing OS.")]
OsNotMounted,
#[error("Supplied key is expected to have at least two words.")]
InvalidSshKey,
#[error("authorized_keys already contains {err}")]
SshKeyAlreadyExists { err: String },
#[error("I/O error: {0}")]
IoError(#[from] io::Error),
#[error("Base64 decoding error: {0}")]
Base64Error(#[from] DecodeError),
#[error("UTF-8 conversion error: {0}")]
Utf8Error(#[from] FromUtf8Error),
}
const SNP_KEYFILE_PATH: &str = "/tmp/detee_snp_keyfile";
const BACKUP_KEYFILE_PATH: &str = "/tmp/detee_backup_keyfile";
@ -15,7 +47,7 @@ pub fn encrypt_and_install_os(
install_url: &str,
install_sha: &str,
keyfile: &str,
) -> Result<String> {
) -> Result<String, OsError> {
let binary_keyfile = BASE64_URL_SAFE.decode(keyfile)?;
std::fs::write(BACKUP_KEYFILE_PATH, binary_keyfile)?;
// this path is hardcoded also in the initrd creation script
@ -27,14 +59,14 @@ pub fn encrypt_and_install_os(
.output()?;
if !install_result.status.success() {
return Err(anyhow!(
"OS installation script failed.\nScript stdout:\n{}\nScript stderr:\n{}",
String::from_utf8(install_result.stdout)
return Err(OsError::InstallationFailed {
stdout: String::from_utf8(install_result.stdout)
.unwrap_or("Could not grab stdout from installation script.".to_string()),
String::from_utf8(install_result.stderr)
stderr: String::from_utf8(install_result.stderr)
.unwrap_or("Could not grab stderr from installation script.".to_string()),
));
});
}
Ok(format!(
"Successfully installed OS. Script stdout:\n{}\nScript stderr:\n{}",
String::from_utf8(install_result.stdout)
@ -44,21 +76,21 @@ pub fn encrypt_and_install_os(
))
}
pub fn try_hot_keyfile() -> Result<()> {
pub fn try_hot_keyfile() -> Result<(), OsError> {
let hot_key = get_derived_key()?;
std::fs::write(SNP_KEYFILE_PATH, hot_key)?;
decrypt_and_mount(SNP_KEYFILE_PATH)?;
Ok(())
}
pub fn try_backup_keyfile(keyfile: &str) -> Result<String> {
pub fn try_backup_keyfile(keyfile: &str) -> Result<String, OsError> {
let binary_keyfile = BASE64_URL_SAFE.decode(keyfile)?;
std::fs::write(BACKUP_KEYFILE_PATH, binary_keyfile)?;
decrypt_and_mount(BACKUP_KEYFILE_PATH)?;
Ok("Succesfully mounted /mnt using backup keyfile.".to_string())
}
fn decrypt_and_mount(keyfile_path: &str) -> Result<()> {
fn decrypt_and_mount(keyfile_path: &str) -> Result<(), OsError> {
let decryption_result = Command::new("cryptsetup")
.arg("open")
.arg("--key-file")
@ -67,27 +99,27 @@ fn decrypt_and_mount(keyfile_path: &str) -> Result<()> {
.arg("root")
.output()?;
if !decryption_result.status.success() {
return Err(anyhow!("Could not decrypt disk."));
return Err(OsError::DecryptionFailed);
}
let mount_result = Command::new("mount").arg("/dev/mapper/root").arg("/mnt").output()?;
if !mount_result.status.success() {
return Err(anyhow!("Could not mount /dev/mapper/root to /mnt"));
return Err(OsError::MountFailed);
}
Ok(())
}
pub fn replace_hot_keyfile() -> Result<String> {
pub fn replace_hot_keyfile() -> Result<String, OsError> {
let _delete_old_keyfile = Command::new("cryptsetup")
.arg("luksKillSlot")
.arg("-d")
.arg(BACKUP_KEYFILE_PATH)
.arg("/dev/vda1")
.arg("1")
.output();
.output()?;
let meta = std::fs::metadata(SNP_KEYFILE_PATH)?;
if meta.len() == 0 {
return Err(anyhow!("Could not replace hot keyfile using SNP KDF."));
return Err(OsError::ReplaceHotKeyfileFailed);
}
let _add_hot_keyfile = Command::new("cryptsetup")
@ -97,28 +129,26 @@ pub fn replace_hot_keyfile() -> Result<String> {
.arg("--new-keyfile")
.arg(SNP_KEYFILE_PATH)
.arg("/dev/vda1")
.output();
.output()?;
Ok("Succesfully replaced hot keyfile using SNP KDF.".to_string())
}
pub fn add_ssh_key(key: &str) -> Result<()> {
pub fn add_ssh_key(key: &str) -> Result<(), OsError> {
use std::os::unix::fs::PermissionsExt;
if !Path::new("/mnt/etc/os-release").try_exists().is_ok_and(|found| found == true) {
return Err(anyhow!(
"Operating system not mounted. Please install OS or decrypt existing OS."
));
return Err(OsError::OsNotMounted);
}
let encoded_key: Vec<&str> = key.split(" ").collect();
if encoded_key.len() < 2 {
return Err(anyhow!("Supplied key is expected to have at least two words."));
return Err(OsError::InvalidSshKey);
}
if let Ok(keys) = File::open("/mnt/.ssh/authorized_keys") {
if let Ok(keys) = File::open("/mnt/root/.ssh/authorized_keys") {
let mut buffered = BufReader::new(keys).lines();
while let Some(Ok(k)) = buffered.next() {
if k.contains(encoded_key[1]) {
return Err(anyhow!("authorized_keys already contains {key}"));
return Err(OsError::SshKeyAlreadyExists { err: key.to_string() });
}
}
} else {
@ -142,6 +172,6 @@ pub fn add_ssh_key(key: &str) -> Result<()> {
Ok(())
}
pub fn list_ssh_keys() -> Result<String> {
pub fn list_ssh_keys() -> Result<String, OsError> {
Ok(std::fs::read_to_string("/mnt/root/.ssh/authorized_keys")?)
}

@ -1,18 +1,31 @@
use anyhow::{Context, Result};
use sev::firmware::guest::{AttestationReport, DerivedKey, Firmware, GuestFieldSelect};
use base64::prelude::{Engine, BASE64_URL_SAFE};
use thiserror::Error;
use sev::error::UserApiError;
use sev::firmware::guest::{AttestationReport, DerivedKey, Firmware, GuestFieldSelect};
fn request_hardware_report(data: [u8; 64]) -> Result<AttestationReport> {
let mut fw = Firmware::open().context("unable to open /dev/sev-guest")?;
Review

I don't see mention of /dev/sev-guest anywhere. I believe it is important to mention this file if the firmware fails to open.

I don't see mention of `/dev/sev-guest` anywhere. I believe it is important to mention this file if the firmware fails to open.
fw.get_report(None, Some(data), Some(0)).context("unable to fetch attestation report")
#[derive(Debug, Error)]
pub enum SNPError {
#[error("Could not parse the derived key: {0}")]
KeyParsingError(#[from] std::num::ParseIntError),
#[error("authorized_keys already contains: {0}")]
UserApiError(#[from] UserApiError),
#[error("I/O error: {0}")]
FirmwareIOError(#[from] std::io::Error),
#[error("bincode Base64 decoding error: {0}")]
Base64Error(#[from] bincode::Error),
}
pub fn get_report_as_base64(data: [u8; 64]) -> Result<String> {
fn request_hardware_report(data: [u8; 64]) -> Result<AttestationReport, SNPError> {
let mut fw = Firmware::open()?;
Ok(fw.get_report(None, Some(data), Some(0))?)
}
pub fn get_report_as_base64(data: [u8; 64]) -> Result<String, SNPError> {
let report = request_hardware_report(data)?;
Ok(BASE64_URL_SAFE.encode(bincode::serialize(&report)?))
}
pub fn get_derived_key() -> Result<String> {
pub fn get_derived_key() -> Result<String, SNPError> {
let mut fw = Firmware::open()?;
let request =
DerivedKey::new(false, GuestFieldSelect(u64::from_str_radix("11111", 2)?), 1, 0, 0);